viewise 0.1.2__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.
viewise-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: viewise
3
+ Version: 0.1.2
4
+ Summary: Python SDK for Viewise semantic telemetry ingestion.
5
+ Author: Viewise
6
+ Project-URL: Homepage, https://github.com/Tanishv/xalpha
7
+ Project-URL: Repository, https://github.com/Tanishv/xalpha
8
+ Project-URL: Issues, https://github.com/Tanishv/xalpha/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: httpx>=0.27
19
+
20
+ # Viewise Python SDK
21
+
22
+ Viewise observes agent runs through a small semantic telemetry contract. The SDK
23
+ sends events to a Viewise backend and keeps agent code resilient when telemetry
24
+ is unavailable.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ python -m pip install viewise
30
+ ```
31
+
32
+ ## Configure
33
+
34
+ Set the runtime environment values copied from the Viewise Setup page:
35
+
36
+ ```bash
37
+ export VIEWISE_BASE_URL="https://viewise-backend.fly.dev"
38
+ export VIEWISE_INGEST_KEY="<workspace-api-key-from-viewise-setup>"
39
+ export VIEWISE_AGENT_REF="workspace.agentx"
40
+ export VIEWISE_AGENT_NAME="agentx"
41
+ ```
42
+
43
+ You can also configure the SDK in code:
44
+
45
+ ```python
46
+ import viewise
47
+
48
+ viewise.configure(
49
+ base_url="https://viewise-backend.fly.dev",
50
+ ingest_key="<workspace-api-key-from-viewise-setup>",
51
+ agent_ref="workspace.agentx",
52
+ agent_name="agentx",
53
+ )
54
+ ```
55
+
56
+ Never commit API keys or place them in prompts, logs, or source code.
57
+
58
+ ## API
59
+
60
+ ```python
61
+ viewise.start_run(run_ref, task, occurred_at=None)
62
+ viewise.observe_evidence(run_ref, source, retrieved_at=None, evidence_ref=None)
63
+ viewise.complete_run(run_ref, result, occurred_at=None)
64
+ viewise.fail_run(run_ref, exc_or_error, occurred_at=None)
65
+ viewise.sanitize_error(exc_or_error)
66
+ ```
67
+
68
+ Supported event types are:
69
+
70
+ - `run.started`
71
+ - `evidence.observed`
72
+ - `run.completed`
73
+ - `run.failed`
74
+
75
+ Top-level telemetry calls return a delivery result. They do not raise when
76
+ Viewise or the network is unavailable, so agent work can continue.
77
+
78
+ ## Smoke Test
79
+
80
+ ```python
81
+ import viewise
82
+
83
+ run_ref = "local-run-001"
84
+
85
+ viewise.start_run(run_ref, "SDK install smoke test")
86
+ viewise.observe_evidence(
87
+ run_ref,
88
+ {"provider": "local", "kind": "smoke"},
89
+ evidence_ref="smoke-001",
90
+ )
91
+ viewise.complete_run(run_ref, {"status": "ok"})
92
+ ```
@@ -0,0 +1,36 @@
1
+ # Financial Agent Lab
2
+
3
+ This repository contains the xalpha observability product and its maintained
4
+ evidence/wiki layer.
5
+
6
+ - [xalpha](xalpha/README.md) is the planned SaaS for observing, explaining, and
7
+ evaluating financial agents. Its current phase and gate status are maintained
8
+ in [xalpha/PHASES.md](xalpha/PHASES.md).
9
+ - [Professor](https://github.com/Tanishv/professor) is the separate dogfood
10
+ financial research agent. It owns research runtime code and local trace
11
+ generation.
12
+
13
+ The [mission wiki](wiki/index.md) keeps historical experiment evidence and
14
+ learnings from Professor that inform xalpha's design. The broader charter remains
15
+ [`vision.md`](vision.md), and durable working rules live in
16
+ [`AGENTS.md`](AGENTS.md).
17
+
18
+ ## Repository map
19
+
20
+ ```text
21
+ xalpha/ SaaS source, local stack, source-of-truth docs, and build guidance
22
+ wiki/ Shared experiments, learnings, roadmap, and distribution notes
23
+ sources/ Shared raw source material and intake rules
24
+ scripts/ Repository-level maintenance checks
25
+ ```
26
+
27
+ ## Offline validation
28
+
29
+ ```bash
30
+ source .venv/bin/activate
31
+ docker compose -f xalpha/docker-compose.yml up -d postgres
32
+ python -m scripts.repository_checks
33
+ ```
34
+
35
+ These commands do not call the OpenAI API. Stop the local PostgreSQL service
36
+ afterward with `docker compose -f xalpha/docker-compose.yml down`.
@@ -0,0 +1,73 @@
1
+ # Viewise Python SDK
2
+
3
+ Viewise observes agent runs through a small semantic telemetry contract. The SDK
4
+ sends events to a Viewise backend and keeps agent code resilient when telemetry
5
+ is unavailable.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ python -m pip install viewise
11
+ ```
12
+
13
+ ## Configure
14
+
15
+ Set the runtime environment values copied from the Viewise Setup page:
16
+
17
+ ```bash
18
+ export VIEWISE_BASE_URL="https://viewise-backend.fly.dev"
19
+ export VIEWISE_INGEST_KEY="<workspace-api-key-from-viewise-setup>"
20
+ export VIEWISE_AGENT_REF="workspace.agentx"
21
+ export VIEWISE_AGENT_NAME="agentx"
22
+ ```
23
+
24
+ You can also configure the SDK in code:
25
+
26
+ ```python
27
+ import viewise
28
+
29
+ viewise.configure(
30
+ base_url="https://viewise-backend.fly.dev",
31
+ ingest_key="<workspace-api-key-from-viewise-setup>",
32
+ agent_ref="workspace.agentx",
33
+ agent_name="agentx",
34
+ )
35
+ ```
36
+
37
+ Never commit API keys or place them in prompts, logs, or source code.
38
+
39
+ ## API
40
+
41
+ ```python
42
+ viewise.start_run(run_ref, task, occurred_at=None)
43
+ viewise.observe_evidence(run_ref, source, retrieved_at=None, evidence_ref=None)
44
+ viewise.complete_run(run_ref, result, occurred_at=None)
45
+ viewise.fail_run(run_ref, exc_or_error, occurred_at=None)
46
+ viewise.sanitize_error(exc_or_error)
47
+ ```
48
+
49
+ Supported event types are:
50
+
51
+ - `run.started`
52
+ - `evidence.observed`
53
+ - `run.completed`
54
+ - `run.failed`
55
+
56
+ Top-level telemetry calls return a delivery result. They do not raise when
57
+ Viewise or the network is unavailable, so agent work can continue.
58
+
59
+ ## Smoke Test
60
+
61
+ ```python
62
+ import viewise
63
+
64
+ run_ref = "local-run-001"
65
+
66
+ viewise.start_run(run_ref, "SDK install smoke test")
67
+ viewise.observe_evidence(
68
+ run_ref,
69
+ {"provider": "local", "kind": "smoke"},
70
+ evidence_ref="smoke-001",
71
+ )
72
+ viewise.complete_run(run_ref, {"status": "ok"})
73
+ ```
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "viewise"
7
+ version = "0.1.2"
8
+ description = "Python SDK for Viewise semantic telemetry ingestion."
9
+ readme = "README.pypi.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = ["httpx>=0.27"]
12
+ authors = [{ name = "Viewise" }]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/Tanishv/xalpha"
25
+ Repository = "https://github.com/Tanishv/xalpha"
26
+ Issues = "https://github.com/Tanishv/xalpha/issues"
27
+
28
+ [tool.setuptools.packages.find]
29
+ include = ["viewise*"]
30
+
31
+ [tool.ruff]
32
+ line-length = 88
33
+ target-version = "py313"
34
+
35
+ [tool.ruff.lint]
36
+ select = ["E", "F", "I", "UP", "B"]
37
+
38
+ [tool.mypy]
39
+ python_version = "3.13"
40
+ strict = true
41
+
42
+ [[tool.mypy.overrides]]
43
+ module = "fastapi.*"
44
+ ignore_missing_imports = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,876 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from datetime import UTC, datetime
9
+ from time import sleep
10
+ from typing import Any, Protocol, cast
11
+ from uuid import UUID, uuid4
12
+
13
+ import httpx
14
+
15
+ __version__ = "0.1.2"
16
+ SCHEMA_VERSION = "1.0"
17
+ EVENTS_ENDPOINT = "/v1/semantic/events"
18
+ SUPPORTED_EVENT_TYPES = frozenset(
19
+ {"run.started", "evidence.observed", "run.completed", "run.failed"}
20
+ )
21
+ CONTEXT_SNAPSHOT_KEYS = frozenset(
22
+ {
23
+ "raw_trace_sha256",
24
+ "artifact_ref",
25
+ "response_id",
26
+ "usage",
27
+ "web_actions_count",
28
+ "cited_sources_count",
29
+ }
30
+ )
31
+ _CREDENTIAL_PATTERNS = (
32
+ re.compile(r"Bearer\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE),
33
+ re.compile(r"xak_[A-Za-z0-9._~+/=-]+"),
34
+ re.compile(r"sk-[A-Za-z0-9._~+/=-]+"),
35
+ re.compile(
36
+ r"(?i)(api[_ -]?key|token|secret|authorization)(['\":=\s]+)"
37
+ r"([A-Za-z0-9._~+/=-]+)"
38
+ ),
39
+ )
40
+ _REQUEST_DETAIL_PATTERNS = (
41
+ re.compile(
42
+ r"(?i)(request[_ -]?id)(['\":=\s]+)([A-Za-z0-9._~+/=-]+)"
43
+ ),
44
+ )
45
+ _SAFE_ERROR_MESSAGE_LIMIT = 500
46
+ _runtime_config: RuntimeConfig | None = None
47
+
48
+
49
+ class ViewiseError(RuntimeError):
50
+ """Base error raised by the Viewise SDK."""
51
+
52
+
53
+ class ViewiseConfigError(ViewiseError):
54
+ """Raised when SDK configuration or call input is invalid."""
55
+
56
+
57
+ class ViewiseAuthError(ViewiseError):
58
+ """Raised when Viewise rejects the configured ingest key."""
59
+
60
+
61
+ class ViewiseRequestError(ViewiseError):
62
+ """Raised when Viewise rejects a semantic event."""
63
+
64
+
65
+ class ViewiseServiceError(ViewiseError):
66
+ """Raised when Viewise or the network cannot accept an event after retries."""
67
+
68
+
69
+ class Response(Protocol):
70
+ status_code: int
71
+
72
+ @property
73
+ def text(self) -> str: ...
74
+
75
+ def json(self) -> object: ...
76
+
77
+
78
+ class HttpSender(Protocol):
79
+ def post(
80
+ self,
81
+ url: str,
82
+ *,
83
+ headers: dict[str, str],
84
+ json: dict[str, Any],
85
+ ) -> Response: ...
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class EventReceipt:
90
+ event_id: str
91
+ run_id: str
92
+ event_type: str
93
+ status: str
94
+ run_status: str
95
+ raw: Mapping[str, object]
96
+ agent_ref: str | None = None
97
+ run_ref: str | None = None
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class DeliveryResult:
102
+ delivered: bool
103
+ event_type: str
104
+ run_ref: str
105
+ event_id: str = ""
106
+ run_id: str = ""
107
+ status: str = ""
108
+ run_status: str = ""
109
+ raw: Mapping[str, object] | None = None
110
+ agent_ref: str | None = None
111
+ error: str | None = None
112
+ skipped_metadata_keys: tuple[str, ...] = ()
113
+
114
+ @classmethod
115
+ def from_receipt(
116
+ cls,
117
+ receipt: EventReceipt,
118
+ *,
119
+ run_ref: str,
120
+ skipped_metadata_keys: tuple[str, ...] = (),
121
+ ) -> DeliveryResult:
122
+ return cls(
123
+ delivered=True,
124
+ event_type=receipt.event_type,
125
+ run_ref=receipt.run_ref or run_ref,
126
+ event_id=receipt.event_id,
127
+ run_id=receipt.run_id,
128
+ status=receipt.status,
129
+ run_status=receipt.run_status,
130
+ raw=receipt.raw,
131
+ agent_ref=receipt.agent_ref,
132
+ skipped_metadata_keys=skipped_metadata_keys,
133
+ )
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class RuntimeConfig:
138
+ base_url: str
139
+ ingest_key: str
140
+ agent_ref: str
141
+ agent_name: str
142
+
143
+
144
+ class HttpxSender:
145
+ def __init__(self, *, timeout: float = 10.0) -> None:
146
+ self._timeout = timeout
147
+
148
+ def post(
149
+ self,
150
+ url: str,
151
+ *,
152
+ headers: dict[str, str],
153
+ json: dict[str, Any],
154
+ ) -> Response:
155
+ return httpx.post(url, headers=headers, json=json, timeout=self._timeout)
156
+
157
+
158
+ class Client:
159
+ def __init__(
160
+ self,
161
+ *,
162
+ base_url: str,
163
+ ingest_key: str | None = None,
164
+ api_key: str | None = None,
165
+ agent_ref: str | None = None,
166
+ agent_id: str | None = None,
167
+ sender: HttpSender | None = None,
168
+ max_attempts: int = 3,
169
+ retry_backoff_seconds: float = 0.0,
170
+ idempotency_prefix: str = "viewise",
171
+ credential_label: str = "ingest key",
172
+ agent_name: str | None = None,
173
+ ) -> None:
174
+ self._base_url = require_non_blank(base_url, "base url").rstrip("/")
175
+ self._credential_label = require_non_blank(credential_label, "credential label")
176
+ self._ingest_key = require_one_key(
177
+ ingest_key=ingest_key,
178
+ api_key=api_key,
179
+ label=self._credential_label,
180
+ )
181
+ self._agent_ref = normalize_ref(agent_ref, "agent ref") if agent_ref else None
182
+ self._agent_id = require_uuid_text(agent_id, "agent id") if agent_id else None
183
+ if (self._agent_ref is None) == (self._agent_id is None):
184
+ raise ViewiseConfigError("exactly one agent_ref or agent_id is required")
185
+ if max_attempts < 1:
186
+ raise ViewiseConfigError("max_attempts must be at least 1")
187
+ if retry_backoff_seconds < 0:
188
+ raise ViewiseConfigError("retry_backoff_seconds cannot be negative")
189
+ self._sender = sender or HttpxSender()
190
+ self._max_attempts = max_attempts
191
+ self._retry_backoff_seconds = retry_backoff_seconds
192
+ self._idempotency_prefix = require_non_blank(
193
+ idempotency_prefix,
194
+ "idempotency prefix",
195
+ )
196
+ self._agent_name = agent_name.strip() if agent_name else None
197
+
198
+ @classmethod
199
+ def from_env(
200
+ cls,
201
+ *,
202
+ sender: HttpSender | None = None,
203
+ max_attempts: int = 3,
204
+ retry_backoff_seconds: float = 0.0,
205
+ ) -> Client:
206
+ base_url = require_env("VIEWISE_BASE_URL")
207
+ ingest_key = os.environ.get("VIEWISE_INGEST_KEY") or os.environ.get(
208
+ "VIEWISE_API_KEY"
209
+ )
210
+ if ingest_key is None or not ingest_key.strip():
211
+ raise ViewiseConfigError(
212
+ "VIEWISE_INGEST_KEY or VIEWISE_API_KEY is required"
213
+ )
214
+ agent_ref = require_env("VIEWISE_AGENT_REF")
215
+ agent_name = require_env("VIEWISE_AGENT_NAME")
216
+ return cls(
217
+ base_url=base_url,
218
+ ingest_key=ingest_key,
219
+ agent_ref=agent_ref,
220
+ agent_name=agent_name,
221
+ sender=sender,
222
+ max_attempts=max_attempts,
223
+ retry_backoff_seconds=retry_backoff_seconds,
224
+ )
225
+
226
+ def start_run(
227
+ self,
228
+ *,
229
+ run_ref: str | None = None,
230
+ run_id: str | None = None,
231
+ task: str,
232
+ occurred_at: datetime | None = None,
233
+ effective_config: Mapping[str, object] | None = None,
234
+ run_context_snapshot: Mapping[str, object] | None = None,
235
+ experiment_id: str | None = None,
236
+ entity_ref: str | None = None,
237
+ ) -> EventReceipt:
238
+ payload: dict[str, object] = {"task": require_non_blank(task, "task")}
239
+ if effective_config is not None:
240
+ payload["effective_config"] = dict(effective_config)
241
+ if run_context_snapshot is not None:
242
+ payload["run_context_snapshot"] = compact_context_snapshot(
243
+ run_context_snapshot
244
+ )
245
+ if experiment_id is not None:
246
+ payload["experiment_id"] = require_non_blank(experiment_id, "experiment id")
247
+ if entity_ref is not None:
248
+ payload["entity_ref"] = require_non_blank(entity_ref, "entity ref")
249
+ return self._post_event(
250
+ run_ref=run_ref,
251
+ run_id=run_id,
252
+ event_type="run.started",
253
+ occurred_at=occurred_at,
254
+ payload=payload,
255
+ )
256
+
257
+ def observe_evidence(
258
+ self,
259
+ *,
260
+ run_ref: str | None = None,
261
+ run_id: str | None = None,
262
+ source: object,
263
+ retrieved_at: datetime,
264
+ evidence_ref: str | None = None,
265
+ occurred_at: datetime | None = None,
266
+ published_at: datetime | None = None,
267
+ effective_at: datetime | None = None,
268
+ effective_period_start_at: datetime | None = None,
269
+ effective_period_end_at: datetime | None = None,
270
+ available_at: datetime | None = None,
271
+ available_at_status: str | None = None,
272
+ ) -> EventReceipt:
273
+ source_text = normalize_source(source)
274
+ payload: dict[str, object] = {
275
+ "source": source_text,
276
+ "retrieved_at": format_timestamp(retrieved_at),
277
+ }
278
+ add_optional_timestamp(payload, "published_at", published_at)
279
+ add_optional_timestamp(payload, "effective_at", effective_at)
280
+ add_optional_timestamp(
281
+ payload,
282
+ "effective_period_start_at",
283
+ effective_period_start_at,
284
+ )
285
+ add_optional_timestamp(
286
+ payload,
287
+ "effective_period_end_at",
288
+ effective_period_end_at,
289
+ )
290
+ if available_at is None and available_at_status == "unknown":
291
+ payload["available_at"] = None
292
+ else:
293
+ add_optional_timestamp(payload, "available_at", available_at)
294
+ if available_at_status is not None:
295
+ payload["available_at_status"] = require_non_blank(
296
+ available_at_status,
297
+ "available_at_status",
298
+ )
299
+ return self._post_event(
300
+ run_ref=run_ref,
301
+ run_id=run_id,
302
+ event_type="evidence.observed",
303
+ occurred_at=occurred_at or retrieved_at,
304
+ payload=payload,
305
+ idempotency_suffix=evidence_ref or source_text,
306
+ )
307
+
308
+ def complete_run(
309
+ self,
310
+ *,
311
+ run_ref: str | None = None,
312
+ run_id: str | None = None,
313
+ output: object,
314
+ occurred_at: datetime | None = None,
315
+ ) -> EventReceipt:
316
+ return self._post_event(
317
+ run_ref=run_ref,
318
+ run_id=run_id,
319
+ event_type="run.completed",
320
+ occurred_at=occurred_at,
321
+ payload={"output": output},
322
+ )
323
+
324
+ def fail_run(
325
+ self,
326
+ *,
327
+ run_ref: str | None = None,
328
+ run_id: str | None = None,
329
+ error: object,
330
+ occurred_at: datetime | None = None,
331
+ ) -> EventReceipt:
332
+ return self._post_event(
333
+ run_ref=run_ref,
334
+ run_id=run_id,
335
+ event_type="run.failed",
336
+ occurred_at=occurred_at,
337
+ payload={"error": error},
338
+ )
339
+
340
+ def _post_event(
341
+ self,
342
+ *,
343
+ run_ref: str | None,
344
+ run_id: str | None,
345
+ event_type: str,
346
+ occurred_at: datetime | None,
347
+ payload: dict[str, object],
348
+ idempotency_suffix: str | None = None,
349
+ ) -> EventReceipt:
350
+ identity = self._identity(run_ref=run_ref, run_id=run_id)
351
+ idempotency_key = self._idempotency_key(
352
+ event_type=event_type,
353
+ identity=identity,
354
+ suffix=idempotency_suffix,
355
+ )
356
+ last_error: Exception | None = None
357
+ for attempt in range(1, self._max_attempts + 1):
358
+ body = {
359
+ **identity,
360
+ "event_id": f"evt_{uuid4()}",
361
+ "schema_version": SCHEMA_VERSION,
362
+ "event_type": event_type,
363
+ "occurred_at": format_timestamp(occurred_at or datetime.now(UTC)),
364
+ "idempotency_key": idempotency_key,
365
+ "payload": payload,
366
+ }
367
+ try:
368
+ response = self._sender.post(
369
+ f"{self._base_url}{EVENTS_ENDPOINT}",
370
+ headers={"Authorization": f"Bearer {self._ingest_key}"},
371
+ json=body,
372
+ )
373
+ except httpx.TransportError as exc:
374
+ last_error = exc
375
+ if attempt < self._max_attempts:
376
+ self._sleep_before_retry()
377
+ continue
378
+ raise ViewiseServiceError(
379
+ redact(
380
+ f"semantic ingestion request failed: {exc}",
381
+ self._ingest_key,
382
+ )
383
+ ) from exc
384
+ if response.status_code >= 500 and attempt < self._max_attempts:
385
+ self._sleep_before_retry()
386
+ continue
387
+ return parse_receipt(
388
+ response,
389
+ event_type=event_type,
390
+ secret=self._ingest_key,
391
+ credential_label=self._credential_label,
392
+ )
393
+ raise ViewiseServiceError(
394
+ redact(f"semantic ingestion request failed: {last_error}", self._ingest_key)
395
+ )
396
+
397
+ def _identity(self, *, run_ref: str | None, run_id: str | None) -> dict[str, str]:
398
+ if self._agent_ref is not None:
399
+ if run_id is not None:
400
+ raise ViewiseConfigError("run_id cannot be used with agent_ref")
401
+ return {
402
+ "agent_ref": self._agent_ref,
403
+ "run_ref": normalize_ref(run_ref, "run ref"),
404
+ }
405
+ if run_ref is not None:
406
+ raise ViewiseConfigError("run_ref cannot be used with agent_id")
407
+ if self._agent_id is None:
408
+ raise ViewiseConfigError("agent_id is required")
409
+ return {
410
+ "agent_id": self._agent_id,
411
+ "run_id": require_uuid_text(run_id, "run id"),
412
+ }
413
+
414
+ def _idempotency_key(
415
+ self,
416
+ *,
417
+ event_type: str,
418
+ identity: Mapping[str, str],
419
+ suffix: str | None,
420
+ ) -> str:
421
+ if "agent_ref" in identity:
422
+ parts = [
423
+ self._idempotency_prefix,
424
+ identity["agent_ref"],
425
+ identity["run_ref"],
426
+ event_type,
427
+ ]
428
+ else:
429
+ parts = [self._idempotency_prefix, event_type, identity["run_id"]]
430
+ if suffix is not None:
431
+ parts.append(normalize_idempotency_part(suffix))
432
+ return ":".join(parts)
433
+
434
+ def _sleep_before_retry(self) -> None:
435
+ if self._retry_backoff_seconds > 0:
436
+ sleep(self._retry_backoff_seconds)
437
+
438
+
439
+ def configure(
440
+ base_url: str,
441
+ ingest_key: str,
442
+ agent_ref: str,
443
+ agent_name: str,
444
+ ) -> None:
445
+ """Configure top-level SDK calls without reading environment variables."""
446
+
447
+ global _runtime_config
448
+ _runtime_config = RuntimeConfig(
449
+ base_url=require_non_blank(base_url, "VIEWISE_BASE_URL"),
450
+ ingest_key=require_non_blank(ingest_key, "VIEWISE_INGEST_KEY"),
451
+ agent_ref=normalize_ref(agent_ref, "VIEWISE_AGENT_REF"),
452
+ agent_name=require_non_blank(agent_name, "VIEWISE_AGENT_NAME"),
453
+ )
454
+
455
+
456
+ def configured_client(*, sender: HttpSender | None = None) -> Client:
457
+ if _runtime_config is None:
458
+ return Client.from_env(sender=sender)
459
+ return Client(
460
+ base_url=_runtime_config.base_url,
461
+ ingest_key=_runtime_config.ingest_key,
462
+ agent_ref=_runtime_config.agent_ref,
463
+ agent_name=_runtime_config.agent_name,
464
+ sender=sender,
465
+ )
466
+
467
+
468
+ def _reset_configuration_for_tests() -> None:
469
+ global _runtime_config
470
+ _runtime_config = None
471
+
472
+
473
+ def start_run(
474
+ run_ref: str,
475
+ task: str,
476
+ occurred_at: datetime | None = None,
477
+ metadata: Mapping[str, object] | None = None,
478
+ *,
479
+ sender: HttpSender | None = None,
480
+ ) -> DeliveryResult:
481
+ mapped, skipped = map_start_metadata(metadata)
482
+ return deliver_safely(
483
+ event_type="run.started",
484
+ run_ref=run_ref,
485
+ skipped_metadata_keys=skipped,
486
+ call=lambda: configured_client(sender=sender).start_run(
487
+ run_ref=run_ref,
488
+ task=task,
489
+ occurred_at=occurred_at,
490
+ effective_config=optional_mapping(mapped.get("effective_config")),
491
+ run_context_snapshot=optional_mapping(mapped.get("run_context_snapshot")),
492
+ experiment_id=optional_str(mapped.get("experiment_id")),
493
+ entity_ref=optional_str(mapped.get("entity_ref")),
494
+ ),
495
+ )
496
+
497
+
498
+ def observe_evidence(
499
+ run_ref: str,
500
+ source: object,
501
+ retrieved_at: datetime | None = None,
502
+ evidence_ref: str | None = None,
503
+ metadata: Mapping[str, object] | None = None,
504
+ *,
505
+ sender: HttpSender | None = None,
506
+ ) -> DeliveryResult:
507
+ mapped, skipped = map_evidence_metadata(metadata)
508
+ observed_at = retrieved_at or datetime.now(UTC)
509
+ return deliver_safely(
510
+ event_type="evidence.observed",
511
+ run_ref=run_ref,
512
+ skipped_metadata_keys=skipped,
513
+ call=lambda: configured_client(sender=sender).observe_evidence(
514
+ run_ref=run_ref,
515
+ source=source,
516
+ retrieved_at=observed_at,
517
+ evidence_ref=evidence_ref,
518
+ occurred_at=optional_datetime(mapped.get("occurred_at")) or observed_at,
519
+ published_at=optional_datetime(mapped.get("published_at")),
520
+ effective_at=optional_datetime(mapped.get("effective_at")),
521
+ effective_period_start_at=optional_datetime(
522
+ mapped.get("effective_period_start_at")
523
+ ),
524
+ effective_period_end_at=optional_datetime(
525
+ mapped.get("effective_period_end_at")
526
+ ),
527
+ available_at=optional_datetime(mapped.get("available_at")),
528
+ available_at_status=optional_str(mapped.get("available_at_status")),
529
+ ),
530
+ )
531
+
532
+
533
+ def complete_run(
534
+ run_ref: str,
535
+ result: object,
536
+ occurred_at: datetime | None = None,
537
+ *,
538
+ sender: HttpSender | None = None,
539
+ ) -> DeliveryResult:
540
+ return deliver_safely(
541
+ event_type="run.completed",
542
+ run_ref=run_ref,
543
+ skipped_metadata_keys=(),
544
+ call=lambda: configured_client(sender=sender).complete_run(
545
+ run_ref=run_ref,
546
+ output=result,
547
+ occurred_at=occurred_at,
548
+ ),
549
+ )
550
+
551
+
552
+ def fail_run(
553
+ run_ref: str,
554
+ exc_or_error: object,
555
+ occurred_at: datetime | None = None,
556
+ *,
557
+ sender: HttpSender | None = None,
558
+ ) -> DeliveryResult:
559
+ return deliver_safely(
560
+ event_type="run.failed",
561
+ run_ref=run_ref,
562
+ skipped_metadata_keys=(),
563
+ call=lambda: configured_client(sender=sender).fail_run(
564
+ run_ref=run_ref,
565
+ error=sanitize_error(exc_or_error),
566
+ occurred_at=occurred_at,
567
+ ),
568
+ )
569
+
570
+
571
+ def deliver_safely(
572
+ *,
573
+ event_type: str,
574
+ run_ref: str,
575
+ skipped_metadata_keys: tuple[str, ...],
576
+ call: Callable[[], EventReceipt],
577
+ ) -> DeliveryResult:
578
+ try:
579
+ return DeliveryResult.from_receipt(
580
+ call(),
581
+ run_ref=run_ref,
582
+ skipped_metadata_keys=skipped_metadata_keys,
583
+ )
584
+ except Exception as exc:
585
+ return DeliveryResult(
586
+ delivered=False,
587
+ event_type=event_type,
588
+ run_ref=run_ref,
589
+ error=redact(
590
+ str(exc),
591
+ os.environ.get("VIEWISE_INGEST_KEY", ""),
592
+ os.environ.get("VIEWISE_API_KEY", ""),
593
+ configured_ingest_key(),
594
+ ),
595
+ skipped_metadata_keys=skipped_metadata_keys,
596
+ )
597
+
598
+
599
+ def map_start_metadata(
600
+ metadata: Mapping[str, object] | None,
601
+ ) -> tuple[dict[str, object], tuple[str, ...]]:
602
+ return map_metadata(
603
+ metadata,
604
+ allowed={
605
+ "effective_config",
606
+ "run_context_snapshot",
607
+ "experiment_id",
608
+ "entity_ref",
609
+ },
610
+ )
611
+
612
+
613
+ def map_evidence_metadata(
614
+ metadata: Mapping[str, object] | None,
615
+ ) -> tuple[dict[str, object], tuple[str, ...]]:
616
+ return map_metadata(
617
+ metadata,
618
+ allowed={
619
+ "occurred_at",
620
+ "published_at",
621
+ "effective_at",
622
+ "effective_period_start_at",
623
+ "effective_period_end_at",
624
+ "available_at",
625
+ "available_at_status",
626
+ },
627
+ )
628
+
629
+
630
+ def map_metadata(
631
+ metadata: Mapping[str, object] | None,
632
+ *,
633
+ allowed: set[str],
634
+ ) -> tuple[dict[str, object], tuple[str, ...]]:
635
+ if metadata is None:
636
+ return {}, ()
637
+ mapped = {key: value for key, value in metadata.items() if key in allowed}
638
+ skipped = tuple(sorted(key for key in metadata if key not in allowed))
639
+ return mapped, skipped
640
+
641
+
642
+ def optional_env(name: str) -> str | None:
643
+ value = os.environ.get(name)
644
+ if value is None or not value.strip():
645
+ return None
646
+ return value.strip()
647
+
648
+
649
+ def optional_mapping(value: object) -> Mapping[str, object] | None:
650
+ if value is None:
651
+ return None
652
+ if not isinstance(value, Mapping):
653
+ raise ViewiseConfigError("metadata value must be an object")
654
+ return cast(Mapping[str, object], value)
655
+
656
+
657
+ def optional_datetime(value: object) -> datetime | None:
658
+ if value is None:
659
+ return None
660
+ if not isinstance(value, datetime):
661
+ raise ViewiseConfigError("metadata timestamp must be a datetime")
662
+ return value
663
+
664
+
665
+ def optional_str(value: object) -> str | None:
666
+ if value is None:
667
+ return None
668
+ if not isinstance(value, str):
669
+ raise ViewiseConfigError("metadata value must be a string")
670
+ return value
671
+
672
+
673
+ def sanitize_error(error: object) -> dict[str, str]:
674
+ if isinstance(error, Mapping) and "message" in error:
675
+ message = str(error.get("message") or "unknown error")
676
+ else:
677
+ message = str(error or "unknown error")
678
+ message = redact(message, *environment_secrets())
679
+ for pattern in _REQUEST_DETAIL_PATTERNS:
680
+ message = pattern.sub(_omit_match, message)
681
+ if len(message) > _SAFE_ERROR_MESSAGE_LIMIT:
682
+ message = message[: _SAFE_ERROR_MESSAGE_LIMIT - 3].rstrip() + "..."
683
+ return {"message": message}
684
+
685
+
686
+ def normalize_source(source: object) -> str:
687
+ if isinstance(source, str):
688
+ return require_non_blank(source, "source")
689
+ try:
690
+ serialized = json.dumps(
691
+ source,
692
+ ensure_ascii=False,
693
+ separators=(",", ":"),
694
+ sort_keys=True,
695
+ )
696
+ except TypeError:
697
+ serialized = str(source)
698
+ return require_non_blank(serialized, "source")
699
+
700
+
701
+ def parse_receipt(
702
+ response: Response,
703
+ *,
704
+ event_type: str,
705
+ secret: str,
706
+ credential_label: str = "ingest key",
707
+ ) -> EventReceipt:
708
+ if response.status_code in (401, 403):
709
+ raise ViewiseAuthError(
710
+ redact(response_detail(response) or f"invalid {credential_label}", secret)
711
+ )
712
+ if response.status_code >= 500:
713
+ detail = response_detail(response) or response.text
714
+ raise ViewiseServiceError(
715
+ redact(
716
+ f"semantic ingestion failed with {response.status_code}: {detail}",
717
+ secret,
718
+ )
719
+ )
720
+ if response.status_code >= 400:
721
+ detail = response_detail(response) or response.text
722
+ raise ViewiseRequestError(
723
+ redact(
724
+ f"semantic ingestion failed with {response.status_code}: {detail}",
725
+ secret,
726
+ )
727
+ )
728
+ try:
729
+ payload = response.json()
730
+ except ValueError as exc:
731
+ raise ViewiseRequestError(
732
+ redact("semantic ingestion returned invalid receipt", secret)
733
+ ) from exc
734
+ if not isinstance(payload, Mapping):
735
+ raise ViewiseRequestError("semantic ingestion returned invalid receipt")
736
+ return EventReceipt(
737
+ event_id=str(payload.get("event_id", "")),
738
+ run_id=str(payload.get("run_id", "")),
739
+ event_type=str(payload.get("event_type", event_type)),
740
+ status=str(payload.get("status", "")),
741
+ run_status=str(payload.get("run_status", "")),
742
+ raw=cast(Mapping[str, object], payload),
743
+ agent_ref=optional_text(payload.get("agent_ref")),
744
+ run_ref=optional_text(payload.get("run_ref")),
745
+ )
746
+
747
+
748
+ def response_detail(response: Response) -> str | None:
749
+ try:
750
+ payload = response.json()
751
+ except ValueError:
752
+ return None
753
+ if isinstance(payload, Mapping):
754
+ detail = payload.get("detail")
755
+ if detail is not None:
756
+ return str(detail)
757
+ return None
758
+
759
+
760
+ def compact_context_snapshot(value: Mapping[str, object]) -> dict[str, object]:
761
+ return {key: value[key] for key in CONTEXT_SNAPSHOT_KEYS if key in value}
762
+
763
+
764
+ def add_optional_timestamp(
765
+ payload: dict[str, object],
766
+ key: str,
767
+ value: datetime | None,
768
+ ) -> None:
769
+ if value is not None:
770
+ payload[key] = format_timestamp(value)
771
+
772
+
773
+ def format_timestamp(value: datetime) -> str:
774
+ if value.tzinfo is None or value.utcoffset() is None:
775
+ raise ViewiseConfigError("timestamps must include timezone")
776
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
777
+
778
+
779
+ def require_env(name: str) -> str:
780
+ return require_non_blank(os.environ.get(name, ""), name)
781
+
782
+
783
+ def require_one_key(
784
+ *,
785
+ ingest_key: str | None,
786
+ api_key: str | None,
787
+ label: str,
788
+ ) -> str:
789
+ if ingest_key is not None and api_key is not None:
790
+ raise ViewiseConfigError("provide only one of ingest_key or api_key")
791
+ return require_non_blank(ingest_key or api_key or "", label)
792
+
793
+
794
+ def require_non_blank(value: str, label: str) -> str:
795
+ stripped = value.strip()
796
+ if not stripped:
797
+ raise ViewiseConfigError(f"{label} is required")
798
+ return stripped
799
+
800
+
801
+ def normalize_ref(value: str | None, label: str) -> str:
802
+ if value is None:
803
+ raise ViewiseConfigError(f"{label} is required")
804
+ stripped = require_non_blank(value, label)
805
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", stripped):
806
+ raise ViewiseConfigError(f"{label} must be a valid ref")
807
+ return stripped
808
+
809
+
810
+ def require_uuid_text(value: str | None, label: str) -> str:
811
+ if value is None:
812
+ raise ViewiseConfigError(f"{label} is required")
813
+ try:
814
+ return str(UUID(require_non_blank(value, label)))
815
+ except ValueError as exc:
816
+ raise ViewiseConfigError(f"{label} must be a valid UUID") from exc
817
+
818
+
819
+ def normalize_idempotency_part(value: str) -> str:
820
+ return require_non_blank(value, "idempotency part").replace("\n", " ")
821
+
822
+
823
+ def optional_text(value: object) -> str | None:
824
+ if value is None:
825
+ return None
826
+ return str(value)
827
+
828
+
829
+ def redact(message: str, *secrets: str) -> str:
830
+ redacted = message
831
+ for secret in secrets:
832
+ if secret:
833
+ redacted = redacted.replace(secret, "[REDACTED]")
834
+ for pattern in _CREDENTIAL_PATTERNS:
835
+ redacted = pattern.sub(_redact_match, redacted)
836
+ return redacted
837
+
838
+
839
+ def environment_secrets() -> tuple[str, ...]:
840
+ names = {
841
+ "VIEWISE_INGEST_KEY",
842
+ "VIEWISE_API_KEY",
843
+ "OPENAI_API_KEY",
844
+ "ANTHROPIC_API_KEY",
845
+ }
846
+ values = []
847
+ for name, value in os.environ.items():
848
+ upper_name = name.upper()
849
+ if name in names or any(
850
+ token in upper_name for token in ("API_KEY", "TOKEN", "SECRET")
851
+ ):
852
+ stripped = value.strip()
853
+ if len(stripped) >= 8:
854
+ values.append(stripped)
855
+ configured = configured_ingest_key()
856
+ if configured:
857
+ values.append(configured)
858
+ return tuple(values)
859
+
860
+
861
+ def configured_ingest_key() -> str:
862
+ if _runtime_config is None:
863
+ return ""
864
+ return _runtime_config.ingest_key
865
+
866
+
867
+ def _redact_match(match: re.Match[str]) -> str:
868
+ if len(match.groups()) >= 3:
869
+ return f"{match.group(1)}{match.group(2)}[REDACTED]"
870
+ if match.group(0).lower().startswith("bearer"):
871
+ return "Bearer [REDACTED]"
872
+ return "[REDACTED]"
873
+
874
+
875
+ def _omit_match(match: re.Match[str]) -> str:
876
+ return f"{match.group(1)}{match.group(2)}[OMITTED]"
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: viewise
3
+ Version: 0.1.2
4
+ Summary: Python SDK for Viewise semantic telemetry ingestion.
5
+ Author: Viewise
6
+ Project-URL: Homepage, https://github.com/Tanishv/xalpha
7
+ Project-URL: Repository, https://github.com/Tanishv/xalpha
8
+ Project-URL: Issues, https://github.com/Tanishv/xalpha/issues
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
+ Requires-Python: >=3.11
17
+ Description-Content-Type: text/markdown
18
+ Requires-Dist: httpx>=0.27
19
+
20
+ # Viewise Python SDK
21
+
22
+ Viewise observes agent runs through a small semantic telemetry contract. The SDK
23
+ sends events to a Viewise backend and keeps agent code resilient when telemetry
24
+ is unavailable.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ python -m pip install viewise
30
+ ```
31
+
32
+ ## Configure
33
+
34
+ Set the runtime environment values copied from the Viewise Setup page:
35
+
36
+ ```bash
37
+ export VIEWISE_BASE_URL="https://viewise-backend.fly.dev"
38
+ export VIEWISE_INGEST_KEY="<workspace-api-key-from-viewise-setup>"
39
+ export VIEWISE_AGENT_REF="workspace.agentx"
40
+ export VIEWISE_AGENT_NAME="agentx"
41
+ ```
42
+
43
+ You can also configure the SDK in code:
44
+
45
+ ```python
46
+ import viewise
47
+
48
+ viewise.configure(
49
+ base_url="https://viewise-backend.fly.dev",
50
+ ingest_key="<workspace-api-key-from-viewise-setup>",
51
+ agent_ref="workspace.agentx",
52
+ agent_name="agentx",
53
+ )
54
+ ```
55
+
56
+ Never commit API keys or place them in prompts, logs, or source code.
57
+
58
+ ## API
59
+
60
+ ```python
61
+ viewise.start_run(run_ref, task, occurred_at=None)
62
+ viewise.observe_evidence(run_ref, source, retrieved_at=None, evidence_ref=None)
63
+ viewise.complete_run(run_ref, result, occurred_at=None)
64
+ viewise.fail_run(run_ref, exc_or_error, occurred_at=None)
65
+ viewise.sanitize_error(exc_or_error)
66
+ ```
67
+
68
+ Supported event types are:
69
+
70
+ - `run.started`
71
+ - `evidence.observed`
72
+ - `run.completed`
73
+ - `run.failed`
74
+
75
+ Top-level telemetry calls return a delivery result. They do not raise when
76
+ Viewise or the network is unavailable, so agent work can continue.
77
+
78
+ ## Smoke Test
79
+
80
+ ```python
81
+ import viewise
82
+
83
+ run_ref = "local-run-001"
84
+
85
+ viewise.start_run(run_ref, "SDK install smoke test")
86
+ viewise.observe_evidence(
87
+ run_ref,
88
+ {"provider": "local", "kind": "smoke"},
89
+ evidence_ref="smoke-001",
90
+ )
91
+ viewise.complete_run(run_ref, {"status": "ok"})
92
+ ```
@@ -0,0 +1,9 @@
1
+ README.md
2
+ README.pypi.md
3
+ pyproject.toml
4
+ viewise/__init__.py
5
+ viewise.egg-info/PKG-INFO
6
+ viewise.egg-info/SOURCES.txt
7
+ viewise.egg-info/dependency_links.txt
8
+ viewise.egg-info/requires.txt
9
+ viewise.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ httpx>=0.27
@@ -0,0 +1 @@
1
+ viewise