tex-sdk 0.3.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.
- tex/__init__.py +20 -0
- tex/client.py +629 -0
- tex/config.py +70 -0
- tex/errors.py +29 -0
- tex/py.typed +0 -0
- tex_sdk-0.3.0.dist-info/METADATA +627 -0
- tex_sdk-0.3.0.dist-info/RECORD +9 -0
- tex_sdk-0.3.0.dist-info/WHEEL +5 -0
- tex_sdk-0.3.0.dist-info/top_level.txt +1 -0
tex/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Tex Python SDK.
|
|
2
|
+
|
|
3
|
+
User-facing, lightweight wrapper around IntegrationBackend HTTP endpoints.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .client import AskResult, Tex
|
|
7
|
+
from .config import TexConfig, TexEndpoints
|
|
8
|
+
from .errors import TexAuthError, TexError, TexHTTPError
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Tex",
|
|
12
|
+
"AskResult",
|
|
13
|
+
"TexConfig",
|
|
14
|
+
"TexEndpoints",
|
|
15
|
+
"TexError",
|
|
16
|
+
"TexAuthError",
|
|
17
|
+
"TexHTTPError",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
__version__ = "0.3.0"
|
tex/client.py
ADDED
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .config import TexConfig
|
|
11
|
+
from .errors import TexAuthError, TexHTTPError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_CID_HEADER = "X-Correlation-ID"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class AskResult:
|
|
19
|
+
"""User-friendly NLQ output."""
|
|
20
|
+
|
|
21
|
+
text: str
|
|
22
|
+
evidence: List[str]
|
|
23
|
+
entities: List[str]
|
|
24
|
+
documents: List[str]
|
|
25
|
+
raw: Dict[str, Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Tex:
|
|
29
|
+
"""Minimal user-facing SDK for IntegrationBackend."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, config: Union[TexConfig, str], **kwargs: Any):
|
|
32
|
+
if isinstance(config, str):
|
|
33
|
+
self._config = TexConfig(base_url=config, **kwargs)
|
|
34
|
+
else:
|
|
35
|
+
self._config = config
|
|
36
|
+
|
|
37
|
+
base_url = self._config.base_url.rstrip("/")
|
|
38
|
+
try:
|
|
39
|
+
self._client = httpx.Client(
|
|
40
|
+
base_url=base_url,
|
|
41
|
+
timeout=self._config.timeout_s,
|
|
42
|
+
http2=self._config.http2,
|
|
43
|
+
headers={"Accept": "application/json"},
|
|
44
|
+
)
|
|
45
|
+
except ImportError as e:
|
|
46
|
+
# httpx requires optional dependency `h2` for HTTP/2.
|
|
47
|
+
# Fall back to HTTP/1.1 to keep the SDK lightweight by default.
|
|
48
|
+
if "h2" in str(e):
|
|
49
|
+
self._client = httpx.Client(
|
|
50
|
+
base_url=base_url,
|
|
51
|
+
timeout=self._config.timeout_s,
|
|
52
|
+
http2=False,
|
|
53
|
+
headers={"Accept": "application/json"},
|
|
54
|
+
)
|
|
55
|
+
self._config.http2 = False
|
|
56
|
+
else:
|
|
57
|
+
raise
|
|
58
|
+
|
|
59
|
+
self._access_token = self._config.access_token
|
|
60
|
+
self._refresh_token = self._config.refresh_token
|
|
61
|
+
self._tenant: Optional[Dict[str, Any]] = None
|
|
62
|
+
|
|
63
|
+
# Capture whether the caller explicitly provided user-scoping inputs.
|
|
64
|
+
# The SDK may later backfill org_id/user_id/session_id from /auth/verify; that should NOT
|
|
65
|
+
# trigger a user login flow unless the caller explicitly requested it.
|
|
66
|
+
self._explicit_org_id = self._config.org_id
|
|
67
|
+
self._explicit_user_id = self._config.user_id
|
|
68
|
+
self._explicit_session_id = self._config.session_id
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self._client.close()
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> "Tex":
|
|
74
|
+
return self
|
|
75
|
+
|
|
76
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
77
|
+
self.close()
|
|
78
|
+
|
|
79
|
+
# ----------------------------- Public API -----------------------------
|
|
80
|
+
|
|
81
|
+
def store_memory(
|
|
82
|
+
self,
|
|
83
|
+
content: Union[str, List[Dict[str, Any]]],
|
|
84
|
+
*,
|
|
85
|
+
type: str = "document",
|
|
86
|
+
format: str = "text",
|
|
87
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
88
|
+
options: Optional[Dict[str, Any]] = None,
|
|
89
|
+
episode_id: Optional[str] = None,
|
|
90
|
+
) -> Dict[str, Any]:
|
|
91
|
+
"""Single ingestion entrypoint.
|
|
92
|
+
|
|
93
|
+
- type="document": content is a string
|
|
94
|
+
- type="episode": content is a string or list of chat messages
|
|
95
|
+
- type="preference": content is ignored; pass preferences via metadata["preferences"]
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
t = type.strip().lower()
|
|
99
|
+
if t == "document":
|
|
100
|
+
if not isinstance(content, str):
|
|
101
|
+
raise ValueError("For type='document', content must be a string")
|
|
102
|
+
payload: Dict[str, Any] = {
|
|
103
|
+
"data": content,
|
|
104
|
+
"format": format,
|
|
105
|
+
"scope": self._scope_payload(),
|
|
106
|
+
"metadata": metadata,
|
|
107
|
+
"options": options,
|
|
108
|
+
}
|
|
109
|
+
return self._post(self._config.endpoints.ingestion_document, payload)
|
|
110
|
+
|
|
111
|
+
if t == "episode":
|
|
112
|
+
messages = self._coerce_messages(content)
|
|
113
|
+
payload = {
|
|
114
|
+
"episode_id": episode_id,
|
|
115
|
+
"messages": messages,
|
|
116
|
+
"scope": self._scope_payload(),
|
|
117
|
+
"metadata": metadata,
|
|
118
|
+
"options": options,
|
|
119
|
+
}
|
|
120
|
+
return self._post(self._config.endpoints.ingestion_episode, payload)
|
|
121
|
+
|
|
122
|
+
if t in ("preference", "preferences"):
|
|
123
|
+
preferences = None
|
|
124
|
+
if metadata and isinstance(metadata, dict):
|
|
125
|
+
preferences = metadata.get("preferences")
|
|
126
|
+
if not isinstance(preferences, list) or not preferences:
|
|
127
|
+
raise ValueError("For type='preference', pass metadata={'preferences': [...]} ")
|
|
128
|
+
scope = self._scope_payload()
|
|
129
|
+
payload = {
|
|
130
|
+
"org_id": scope.get("org_id") or "_",
|
|
131
|
+
"user_id": scope.get("user_id") or "_",
|
|
132
|
+
"session_id": self._config.session_id,
|
|
133
|
+
"preferences": preferences,
|
|
134
|
+
}
|
|
135
|
+
return self._post(self._config.endpoints.ingestion_preference, payload)
|
|
136
|
+
|
|
137
|
+
raise ValueError("type must be one of: document, episode, preference")
|
|
138
|
+
|
|
139
|
+
def job(self, job_id: str) -> Dict[str, Any]:
|
|
140
|
+
path = self._config.endpoints.ingestion_status.format(job_id=job_id)
|
|
141
|
+
return self._get(path)
|
|
142
|
+
|
|
143
|
+
def query(self, query: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
144
|
+
payload = {"query": query, "params": params or {}}
|
|
145
|
+
return self._post(self._config.endpoints.db_query, payload)
|
|
146
|
+
|
|
147
|
+
def schema(self) -> Dict[str, Any]:
|
|
148
|
+
return self._get(self._config.endpoints.db_schema)
|
|
149
|
+
|
|
150
|
+
def ask(
|
|
151
|
+
self,
|
|
152
|
+
question: str,
|
|
153
|
+
*,
|
|
154
|
+
execute: bool = True,
|
|
155
|
+
enable_pruning: bool = True,
|
|
156
|
+
use_local_intelligence: bool = True,
|
|
157
|
+
intent_match_options: Optional[Dict[str, Any]] = None,
|
|
158
|
+
compact: bool = True,
|
|
159
|
+
) -> AskResult:
|
|
160
|
+
payload: Dict[str, Any] = {
|
|
161
|
+
"query": question,
|
|
162
|
+
"execute": execute,
|
|
163
|
+
"enable_pruning": enable_pruning,
|
|
164
|
+
"use_local_intelligence": use_local_intelligence,
|
|
165
|
+
"compact": compact,
|
|
166
|
+
}
|
|
167
|
+
if intent_match_options is not None:
|
|
168
|
+
payload["intent_match_options"] = intent_match_options
|
|
169
|
+
|
|
170
|
+
raw = self._post(self._config.endpoints.nlq_execute, payload)
|
|
171
|
+
return self._simplify_nlq(raw)
|
|
172
|
+
|
|
173
|
+
def whoami(self) -> Dict[str, Any]:
|
|
174
|
+
"""Return the tenant context as seen by IntegrationBackend (useful for debugging)."""
|
|
175
|
+
self._ensure_auth()
|
|
176
|
+
self._ensure_tenant()
|
|
177
|
+
return dict(self._tenant or {})
|
|
178
|
+
|
|
179
|
+
# ----------------------------- Search ---------------------------------
|
|
180
|
+
|
|
181
|
+
def search(
|
|
182
|
+
self,
|
|
183
|
+
query: str,
|
|
184
|
+
*,
|
|
185
|
+
top_k: int = 10,
|
|
186
|
+
min_score: Optional[float] = None,
|
|
187
|
+
label: Optional[str] = None,
|
|
188
|
+
metadata_filter: Optional[Dict[str, Any]] = None,
|
|
189
|
+
) -> Dict[str, Any]:
|
|
190
|
+
"""Fast semantic vector search across all indexed nodes.
|
|
191
|
+
|
|
192
|
+
Returns ``{ results: [{ id, label, score, properties, content_preview }], total_results, query }``
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
payload: Dict[str, Any] = {"query": query, "top_k": top_k}
|
|
196
|
+
if min_score is not None:
|
|
197
|
+
payload["min_score"] = min_score
|
|
198
|
+
if label is not None:
|
|
199
|
+
payload["label"] = label
|
|
200
|
+
if metadata_filter is not None:
|
|
201
|
+
payload["metadata_filter"] = metadata_filter
|
|
202
|
+
return self._post(self._config.endpoints.search, payload)
|
|
203
|
+
|
|
204
|
+
# ----------------------------- Memories CRUD --------------------------
|
|
205
|
+
|
|
206
|
+
def get_memory(self, memory_id: str) -> Dict[str, Any]:
|
|
207
|
+
"""Retrieve a single memory by ID."""
|
|
208
|
+
path = self._config.endpoints.memories_get.format(memory_id=memory_id)
|
|
209
|
+
return self._get(path)
|
|
210
|
+
|
|
211
|
+
def list_memories(
|
|
212
|
+
self,
|
|
213
|
+
*,
|
|
214
|
+
type: Optional[str] = None,
|
|
215
|
+
limit: int = 50,
|
|
216
|
+
offset: int = 0,
|
|
217
|
+
) -> Dict[str, Any]:
|
|
218
|
+
"""List memories with optional type filter and pagination.
|
|
219
|
+
|
|
220
|
+
Returns ``{ memories: [...], total, limit, offset }``
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
224
|
+
if type is not None:
|
|
225
|
+
params["type"] = type
|
|
226
|
+
return self._request("GET", self._config.endpoints.memories_list, query_params=params)
|
|
227
|
+
|
|
228
|
+
def delete_memory(self, memory_id: str) -> Dict[str, Any]:
|
|
229
|
+
"""Soft-delete a memory by ID.
|
|
230
|
+
|
|
231
|
+
Returns ``{ deleted: true, memory_id }``
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
path = self._config.endpoints.memories_delete.format(memory_id=memory_id)
|
|
235
|
+
return self._request("DELETE", path)
|
|
236
|
+
|
|
237
|
+
def delete_memories(self, *, user_id: Optional[str] = None) -> Dict[str, Any]:
|
|
238
|
+
"""Bulk soft-delete memories.
|
|
239
|
+
|
|
240
|
+
- If `user_id` is omitted, deletes the caller's own memories.
|
|
241
|
+
- If `user_id` is provided and differs from the caller, IntegrationBackend requires
|
|
242
|
+
elevated roles.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
params: Dict[str, Any] = {}
|
|
246
|
+
if user_id is not None:
|
|
247
|
+
params["user_id"] = user_id
|
|
248
|
+
return self._request(
|
|
249
|
+
"DELETE",
|
|
250
|
+
self._config.endpoints.memories_list,
|
|
251
|
+
query_params=params,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
def update_memory(
|
|
255
|
+
self,
|
|
256
|
+
memory_id: str,
|
|
257
|
+
*,
|
|
258
|
+
content: Optional[str] = None,
|
|
259
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
260
|
+
options: Optional[Dict[str, Any]] = None,
|
|
261
|
+
) -> Dict[str, Any]:
|
|
262
|
+
"""Update a memory by ID."""
|
|
263
|
+
|
|
264
|
+
payload: Dict[str, Any] = {}
|
|
265
|
+
if content is not None:
|
|
266
|
+
payload["content"] = content
|
|
267
|
+
if metadata is not None:
|
|
268
|
+
payload["metadata"] = metadata
|
|
269
|
+
if options is not None:
|
|
270
|
+
payload["options"] = options
|
|
271
|
+
|
|
272
|
+
path = self._config.endpoints.memories_update.format(memory_id=memory_id)
|
|
273
|
+
return self._request("PATCH", path, json_body=payload)
|
|
274
|
+
|
|
275
|
+
# ----------------------------- Episodes -------------------------------
|
|
276
|
+
|
|
277
|
+
def list_episodes(
|
|
278
|
+
self,
|
|
279
|
+
*,
|
|
280
|
+
limit: int = 50,
|
|
281
|
+
offset: int = 0,
|
|
282
|
+
since: Optional[str] = None,
|
|
283
|
+
) -> Dict[str, Any]:
|
|
284
|
+
"""List episodic memory entries.
|
|
285
|
+
|
|
286
|
+
Returns ``{ episodes: [...], total, limit, offset }``
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
params: Dict[str, Any] = {"limit": limit, "offset": offset}
|
|
290
|
+
if since is not None:
|
|
291
|
+
params["since"] = since
|
|
292
|
+
return self._request("GET", self._config.endpoints.episodes_list, query_params=params)
|
|
293
|
+
|
|
294
|
+
# ----------------------------- User Profile ----------------------------
|
|
295
|
+
|
|
296
|
+
def get_profile(self, *, format: str = "text") -> Dict[str, Any]:
|
|
297
|
+
"""Return synthesised user profile from preferences and episodic memories.
|
|
298
|
+
|
|
299
|
+
Returns ``{ user_id, preferences: [...], profile_text, episode_count }``
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
return self._request("GET", self._config.endpoints.user_profile, query_params={"format": format})
|
|
303
|
+
|
|
304
|
+
# ----------------------------- Batch Ingestion -------------------------
|
|
305
|
+
|
|
306
|
+
def batch_store(
|
|
307
|
+
self,
|
|
308
|
+
documents: Iterable[Dict[str, Any]],
|
|
309
|
+
) -> Dict[str, Any]:
|
|
310
|
+
"""Ingest multiple documents in one call.
|
|
311
|
+
|
|
312
|
+
Each document dict should have: ``data`` (str), and optionally
|
|
313
|
+
``format``, ``metadata``, ``options``.
|
|
314
|
+
|
|
315
|
+
Returns ``{ batch_id, total_documents, job_ids: [...] }``
|
|
316
|
+
"""
|
|
317
|
+
|
|
318
|
+
payload = {"documents": list(documents)}
|
|
319
|
+
return self._post(self._config.endpoints.ingestion_batch, payload)
|
|
320
|
+
|
|
321
|
+
# ---------------------------- Internal bits ----------------------------
|
|
322
|
+
|
|
323
|
+
def _scope_payload(self) -> Dict[str, Any]:
|
|
324
|
+
# IntegrationBackend ultimately uses tenant from JWT; `scope` is still required by the request model.
|
|
325
|
+
# For API-key auth, org_id/user_id may not be provided by the user; we lazily discover via /auth/verify.
|
|
326
|
+
self._ensure_auth()
|
|
327
|
+
if not self._config.org_id or not self._config.user_id:
|
|
328
|
+
self._ensure_tenant()
|
|
329
|
+
|
|
330
|
+
org_id = self._config.org_id or (self._tenant or {}).get("org_id") or "_"
|
|
331
|
+
user_id = self._config.user_id or (self._tenant or {}).get("user_id") or "_"
|
|
332
|
+
session_id = self._config.session_id or (self._tenant or {}).get("session_id")
|
|
333
|
+
|
|
334
|
+
return {"org_id": org_id, "user_id": user_id, "session_id": session_id}
|
|
335
|
+
|
|
336
|
+
def _ensure_tenant(self) -> None:
|
|
337
|
+
if self._tenant is not None and self._tenant.get("org_id") and self._tenant.get("user_id"):
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
# Call /auth/verify to get tenant claims (org_id/user_id/session_id)
|
|
341
|
+
try:
|
|
342
|
+
data = self._request("GET", self._config.endpoints.auth_verify)
|
|
343
|
+
tenant = data.get("tenant") if isinstance(data, dict) else None
|
|
344
|
+
if isinstance(tenant, dict):
|
|
345
|
+
self._tenant = tenant
|
|
346
|
+
# Backfill config fields so subsequent requests avoid verify call.
|
|
347
|
+
if not self._config.org_id and isinstance(tenant.get("org_id"), str):
|
|
348
|
+
self._config.org_id = tenant.get("org_id")
|
|
349
|
+
if not self._config.user_id and isinstance(tenant.get("user_id"), str):
|
|
350
|
+
self._config.user_id = tenant.get("user_id")
|
|
351
|
+
if not self._config.session_id and isinstance(tenant.get("session_id"), str):
|
|
352
|
+
self._config.session_id = tenant.get("session_id")
|
|
353
|
+
return
|
|
354
|
+
except TexHTTPError:
|
|
355
|
+
# Non-fatal: we can still send placeholder scope; backend ignores it and uses JWT tenant.
|
|
356
|
+
pass
|
|
357
|
+
|
|
358
|
+
self._tenant = self._tenant or {}
|
|
359
|
+
|
|
360
|
+
def _coerce_messages(self, content: Union[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]:
|
|
361
|
+
if isinstance(content, str):
|
|
362
|
+
return [{"role": "user", "content": content}]
|
|
363
|
+
if isinstance(content, list) and content and all(isinstance(m, dict) for m in content):
|
|
364
|
+
return content
|
|
365
|
+
raise ValueError("For type='episode', content must be a string or list of {role, content} messages")
|
|
366
|
+
|
|
367
|
+
def _simplify_nlq(self, raw: Dict[str, Any]) -> AskResult:
|
|
368
|
+
evidence: List[str] = []
|
|
369
|
+
entities: List[str] = []
|
|
370
|
+
documents: List[str] = []
|
|
371
|
+
|
|
372
|
+
intent_matches = (raw.get("intent_matches") or {}) if isinstance(raw, dict) else {}
|
|
373
|
+
matches = intent_matches.get("matches") if isinstance(intent_matches, dict) else None
|
|
374
|
+
if isinstance(matches, list):
|
|
375
|
+
for match in matches:
|
|
376
|
+
if not isinstance(match, dict):
|
|
377
|
+
continue
|
|
378
|
+
for ev in match.get("evidence", []) or []:
|
|
379
|
+
if isinstance(ev, dict):
|
|
380
|
+
txt = ev.get("text")
|
|
381
|
+
if isinstance(txt, str) and txt.strip():
|
|
382
|
+
evidence.append(txt.strip())
|
|
383
|
+
bindings = match.get("bindings")
|
|
384
|
+
if isinstance(bindings, dict):
|
|
385
|
+
for b in bindings.values():
|
|
386
|
+
if isinstance(b, dict):
|
|
387
|
+
name = b.get("entity_name") or b.get("canonical_id")
|
|
388
|
+
if isinstance(name, str) and name.strip():
|
|
389
|
+
entities.append(name.strip())
|
|
390
|
+
for d in match.get("documents", []) or []:
|
|
391
|
+
if isinstance(d, dict):
|
|
392
|
+
name = d.get("entity_name") or d.get("canonical_id")
|
|
393
|
+
if isinstance(name, str) and name.strip():
|
|
394
|
+
documents.append(name.strip())
|
|
395
|
+
|
|
396
|
+
evidence = list(dict.fromkeys(evidence))
|
|
397
|
+
entities = list(dict.fromkeys(entities))
|
|
398
|
+
documents = list(dict.fromkeys(documents))
|
|
399
|
+
|
|
400
|
+
lines: List[str] = []
|
|
401
|
+
if evidence:
|
|
402
|
+
lines.append("Evidence:\n" + "\n".join(f"- {e}" for e in evidence[:50]))
|
|
403
|
+
if entities:
|
|
404
|
+
lines.append("Entities:\n" + "\n".join(f"- {e}" for e in entities[:50]))
|
|
405
|
+
if documents:
|
|
406
|
+
lines.append("Documents:\n" + "\n".join(f"- {d}" for d in documents[:50]))
|
|
407
|
+
text = "\n\n".join(lines).strip() or "No evidence returned."
|
|
408
|
+
|
|
409
|
+
return AskResult(text=text, evidence=evidence, entities=entities, documents=documents, raw=raw)
|
|
410
|
+
|
|
411
|
+
def _ensure_auth(self) -> None:
|
|
412
|
+
if self._access_token:
|
|
413
|
+
return
|
|
414
|
+
ep = self._config.endpoints
|
|
415
|
+
|
|
416
|
+
if self._config.access_token:
|
|
417
|
+
self._access_token = self._config.access_token
|
|
418
|
+
self._refresh_token = self._config.refresh_token
|
|
419
|
+
return
|
|
420
|
+
|
|
421
|
+
if self._config.api_key:
|
|
422
|
+
# Exchange org API key -> JWT
|
|
423
|
+
tokens = self._post_noauth(ep.auth_token_exchange, {"api_key": self._config.api_key})
|
|
424
|
+
access = tokens.get("access_token")
|
|
425
|
+
refresh = tokens.get("refresh_token")
|
|
426
|
+
if not access:
|
|
427
|
+
raise TexAuthError("Token exchange did not return access_token", status_code=401)
|
|
428
|
+
self._access_token = access
|
|
429
|
+
self._refresh_token = refresh
|
|
430
|
+
|
|
431
|
+
# Discover org_id/user_id/session_id from /auth/verify
|
|
432
|
+
self._ensure_tenant()
|
|
433
|
+
|
|
434
|
+
# Optional: if user_id was explicitly provided by the caller, mint a user-scoped JWT via /auth/login.
|
|
435
|
+
if self._explicit_user_id:
|
|
436
|
+
org_id = self._explicit_org_id or self._config.org_id or (self._tenant or {}).get("org_id")
|
|
437
|
+
if not isinstance(org_id, str) or not org_id:
|
|
438
|
+
raise TexAuthError("Could not determine org_id for user login", status_code=401)
|
|
439
|
+
login_tokens = self._post_noauth(
|
|
440
|
+
ep.auth_login,
|
|
441
|
+
{
|
|
442
|
+
"org_id": org_id,
|
|
443
|
+
"user_id": self._explicit_user_id,
|
|
444
|
+
"session_id": self._explicit_session_id,
|
|
445
|
+
},
|
|
446
|
+
)
|
|
447
|
+
self._access_token = login_tokens.get("access_token")
|
|
448
|
+
self._refresh_token = login_tokens.get("refresh_token")
|
|
449
|
+
if not self._access_token:
|
|
450
|
+
raise TexAuthError("Login did not return access_token", status_code=401)
|
|
451
|
+
self._tenant = None
|
|
452
|
+
self._ensure_tenant()
|
|
453
|
+
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
if self._config.org_id and self._config.user_id:
|
|
457
|
+
tokens = self._post_noauth(
|
|
458
|
+
ep.auth_login,
|
|
459
|
+
{
|
|
460
|
+
"org_id": self._config.org_id,
|
|
461
|
+
"user_id": self._config.user_id,
|
|
462
|
+
"session_id": self._config.session_id,
|
|
463
|
+
},
|
|
464
|
+
)
|
|
465
|
+
self._access_token = tokens.get("access_token")
|
|
466
|
+
self._refresh_token = tokens.get("refresh_token")
|
|
467
|
+
if not self._access_token:
|
|
468
|
+
raise TexAuthError("Login did not return access_token", status_code=401)
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
raise TexAuthError(
|
|
472
|
+
"No auth configured. Provide api_key, or org_id+user_id, or access_token.",
|
|
473
|
+
status_code=401,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def _refresh(self) -> None:
|
|
477
|
+
ep = self._config.endpoints
|
|
478
|
+
if self._refresh_token:
|
|
479
|
+
tokens = self._post_noauth(ep.auth_refresh, {"refresh_token": self._refresh_token})
|
|
480
|
+
self._access_token = tokens.get("access_token")
|
|
481
|
+
self._refresh_token = tokens.get("refresh_token")
|
|
482
|
+
if self._access_token:
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
# Fallback: re-exchange api key
|
|
486
|
+
if self._config.api_key:
|
|
487
|
+
if self._explicit_user_id:
|
|
488
|
+
org_id = self._explicit_org_id or self._config.org_id or (self._tenant or {}).get("org_id")
|
|
489
|
+
if not org_id:
|
|
490
|
+
self._access_token = None
|
|
491
|
+
self._refresh_token = None
|
|
492
|
+
self._tenant = None
|
|
493
|
+
self._ensure_auth()
|
|
494
|
+
return
|
|
495
|
+
login_tokens = self._post_noauth(
|
|
496
|
+
ep.auth_login,
|
|
497
|
+
{
|
|
498
|
+
"org_id": org_id,
|
|
499
|
+
"user_id": self._explicit_user_id,
|
|
500
|
+
"session_id": self._explicit_session_id,
|
|
501
|
+
},
|
|
502
|
+
)
|
|
503
|
+
self._access_token = login_tokens.get("access_token")
|
|
504
|
+
self._refresh_token = login_tokens.get("refresh_token")
|
|
505
|
+
if self._access_token:
|
|
506
|
+
self._tenant = None
|
|
507
|
+
return
|
|
508
|
+
|
|
509
|
+
self._access_token = None
|
|
510
|
+
self._refresh_token = None
|
|
511
|
+
self._tenant = None
|
|
512
|
+
self._ensure_auth()
|
|
513
|
+
return
|
|
514
|
+
|
|
515
|
+
raise TexAuthError("Authentication expired and could not be refreshed", status_code=401)
|
|
516
|
+
|
|
517
|
+
def _headers(self) -> Dict[str, str]:
|
|
518
|
+
self._ensure_auth()
|
|
519
|
+
return {
|
|
520
|
+
"Authorization": f"Bearer {self._access_token}",
|
|
521
|
+
_CID_HEADER: str(uuid.uuid4()),
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
def _request(
|
|
525
|
+
self,
|
|
526
|
+
method: str,
|
|
527
|
+
path: str,
|
|
528
|
+
json_body: Any = None,
|
|
529
|
+
query_params: Optional[Dict[str, Any]] = None,
|
|
530
|
+
) -> Dict[str, Any]:
|
|
531
|
+
headers = self._headers()
|
|
532
|
+
try:
|
|
533
|
+
resp = self._client.request(method, path, json=json_body, params=query_params, headers=headers)
|
|
534
|
+
except httpx.TimeoutException as e:
|
|
535
|
+
raise TexHTTPError("Request timed out", details=str(e))
|
|
536
|
+
except httpx.HTTPError as e:
|
|
537
|
+
raise TexHTTPError("Network error", details=str(e))
|
|
538
|
+
|
|
539
|
+
if resp.status_code == 401:
|
|
540
|
+
self._refresh()
|
|
541
|
+
headers = self._headers()
|
|
542
|
+
resp = self._client.request(method, path, json=json_body, params=query_params, headers=headers)
|
|
543
|
+
|
|
544
|
+
request_id = resp.headers.get(_CID_HEADER)
|
|
545
|
+
if resp.status_code >= 400:
|
|
546
|
+
msg, details = self._safe_error_from_response(resp)
|
|
547
|
+
msg = self._decorate_error_message(msg, resp.status_code, details)
|
|
548
|
+
exc_cls = TexAuthError if resp.status_code in (401, 403) else TexHTTPError
|
|
549
|
+
raise exc_cls(
|
|
550
|
+
msg,
|
|
551
|
+
status_code=resp.status_code,
|
|
552
|
+
request_id=request_id,
|
|
553
|
+
response_text=(resp.text[:2000] if resp.text else None),
|
|
554
|
+
details=details,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if not resp.content:
|
|
558
|
+
return {}
|
|
559
|
+
try:
|
|
560
|
+
data = resp.json()
|
|
561
|
+
return data if isinstance(data, dict) else {"data": data}
|
|
562
|
+
except Exception:
|
|
563
|
+
return {"data": resp.text}
|
|
564
|
+
|
|
565
|
+
def _safe_error_from_response(self, resp: httpx.Response) -> Tuple[str, Any]:
|
|
566
|
+
try:
|
|
567
|
+
payload = resp.json()
|
|
568
|
+
if isinstance(payload, dict):
|
|
569
|
+
detail = payload.get("detail")
|
|
570
|
+
if isinstance(detail, str) and detail.strip():
|
|
571
|
+
return detail, payload
|
|
572
|
+
if detail is not None:
|
|
573
|
+
try:
|
|
574
|
+
return json.dumps(detail, ensure_ascii=False), payload
|
|
575
|
+
except Exception:
|
|
576
|
+
return str(detail), payload
|
|
577
|
+
|
|
578
|
+
message = payload.get("message")
|
|
579
|
+
if isinstance(message, str) and message.strip():
|
|
580
|
+
return message, payload
|
|
581
|
+
return "Request failed", payload
|
|
582
|
+
return "Request failed", payload
|
|
583
|
+
except Exception:
|
|
584
|
+
return "Request failed", resp.text
|
|
585
|
+
|
|
586
|
+
def _decorate_error_message(self, msg: str, status_code: int, details: Any) -> str:
|
|
587
|
+
if not isinstance(msg, str) or not msg.strip():
|
|
588
|
+
return "Request failed"
|
|
589
|
+
|
|
590
|
+
if status_code == 422:
|
|
591
|
+
lowered = msg.lower()
|
|
592
|
+
if "intentgraph validation" in lowered or "root variable must have a type" in lowered:
|
|
593
|
+
return (
|
|
594
|
+
f"{msg.strip()}\n\n"
|
|
595
|
+
"Hint: this is usually a planner/schema issue. Verify the DB has a non-empty schema "
|
|
596
|
+
"and that the backend can fetch it (e.g., GET /schema-json and POST /planner/plan)."
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
return msg.strip()
|
|
600
|
+
|
|
601
|
+
def _get(self, path: str) -> Dict[str, Any]:
|
|
602
|
+
return self._request("GET", path)
|
|
603
|
+
|
|
604
|
+
def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
605
|
+
return self._request("POST", path, payload)
|
|
606
|
+
|
|
607
|
+
def _post_noauth(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
608
|
+
headers = {_CID_HEADER: str(uuid.uuid4())}
|
|
609
|
+
try:
|
|
610
|
+
resp = self._client.post(path, json=payload, headers=headers)
|
|
611
|
+
except httpx.TimeoutException as e:
|
|
612
|
+
raise TexHTTPError("Request timed out", details=str(e))
|
|
613
|
+
except httpx.HTTPError as e:
|
|
614
|
+
raise TexHTTPError("Network error", details=str(e))
|
|
615
|
+
|
|
616
|
+
request_id = resp.headers.get(_CID_HEADER)
|
|
617
|
+
if resp.status_code >= 400:
|
|
618
|
+
msg, details = self._safe_error_from_response(resp)
|
|
619
|
+
msg = self._decorate_error_message(msg, resp.status_code, details)
|
|
620
|
+
raise TexAuthError(
|
|
621
|
+
msg,
|
|
622
|
+
status_code=resp.status_code,
|
|
623
|
+
request_id=request_id,
|
|
624
|
+
response_text=(resp.text[:2000] if resp.text else None),
|
|
625
|
+
details=details,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
data = resp.json()
|
|
629
|
+
return data if isinstance(data, dict) else {"data": data}
|
tex/config.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class TexEndpoints:
|
|
9
|
+
"""IntegrationBackend endpoint paths (override to match deployments)."""
|
|
10
|
+
|
|
11
|
+
# Auth
|
|
12
|
+
auth_login: str = "/auth/login"
|
|
13
|
+
auth_refresh: str = "/auth/refresh"
|
|
14
|
+
auth_token_exchange: str = "/auth/token-exchange"
|
|
15
|
+
auth_verify: str = "/auth/verify"
|
|
16
|
+
|
|
17
|
+
# Core ingestion
|
|
18
|
+
ingestion_document: str = "/ingestion/document"
|
|
19
|
+
ingestion_episode: str = "/ingestion/episode"
|
|
20
|
+
ingestion_preference: str = "/ingestion/preference"
|
|
21
|
+
ingestion_status: str = "/ingestion/status/{job_id}"
|
|
22
|
+
ingestion_batch: str = "/ingestion/batch"
|
|
23
|
+
|
|
24
|
+
# DB
|
|
25
|
+
db_query: str = "/helixdb/query"
|
|
26
|
+
db_schema: str = "/helixdb/schema"
|
|
27
|
+
|
|
28
|
+
# NLQ
|
|
29
|
+
nlq_execute: str = "/nlq/execute"
|
|
30
|
+
|
|
31
|
+
# Search
|
|
32
|
+
search: str = "/search"
|
|
33
|
+
|
|
34
|
+
# Memories CRUD
|
|
35
|
+
memories_list: str = "/memories"
|
|
36
|
+
memories_get: str = "/memories/{memory_id}"
|
|
37
|
+
memories_delete: str = "/memories/{memory_id}"
|
|
38
|
+
memories_update: str = "/memories/{memory_id}"
|
|
39
|
+
|
|
40
|
+
# Episodes
|
|
41
|
+
episodes_list: str = "/memories/episodes"
|
|
42
|
+
|
|
43
|
+
# Users
|
|
44
|
+
user_profile: str = "/users/profile"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class TexConfig:
|
|
49
|
+
"""SDK configuration.
|
|
50
|
+
|
|
51
|
+
Auth options:
|
|
52
|
+
- api_key: exchanges via /auth/token-exchange
|
|
53
|
+
- org_id + user_id (+ optional session_id): obtains tokens via /auth/login
|
|
54
|
+
- access_token: directly uses provided token
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
base_url: str
|
|
58
|
+
|
|
59
|
+
# One of these auth modes should be set
|
|
60
|
+
api_key: Optional[str] = None
|
|
61
|
+
org_id: Optional[str] = None
|
|
62
|
+
user_id: Optional[str] = None
|
|
63
|
+
session_id: Optional[str] = None
|
|
64
|
+
access_token: Optional[str] = None
|
|
65
|
+
refresh_token: Optional[str] = None
|
|
66
|
+
|
|
67
|
+
# Transport
|
|
68
|
+
timeout_s: float = 15.0
|
|
69
|
+
http2: bool = True
|
|
70
|
+
endpoints: TexEndpoints = field(default_factory=TexEndpoints)
|
tex/errors.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TexError(RuntimeError):
|
|
8
|
+
"""Base SDK error."""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class TexHTTPError(TexError):
|
|
13
|
+
message: str
|
|
14
|
+
status_code: Optional[int] = None
|
|
15
|
+
request_id: Optional[str] = None
|
|
16
|
+
response_text: Optional[str] = None
|
|
17
|
+
details: Any = None
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
base = self.message
|
|
21
|
+
if self.status_code is not None:
|
|
22
|
+
base = f"HTTP {self.status_code}: {base}"
|
|
23
|
+
if self.request_id:
|
|
24
|
+
base = f"{base} (request_id={self.request_id})"
|
|
25
|
+
return base
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TexAuthError(TexHTTPError):
|
|
29
|
+
"""Authentication/authorization error."""
|
tex/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tex-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Tex Python SDK for IntegrationBackend
|
|
5
|
+
Author: Tex
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: httpx>=0.24.0
|
|
9
|
+
Provides-Extra: http2
|
|
10
|
+
Requires-Dist: h2>=4.1.0; extra == "http2"
|
|
11
|
+
|
|
12
|
+
# Tex SDK (Python)
|
|
13
|
+
|
|
14
|
+
Tex is a lightweight Python client for Tex’s IntegrationBackend HTTP API.
|
|
15
|
+
|
|
16
|
+
This README is intentionally implementation-grounded: it documents exactly what the SDK does today based on the code in:
|
|
17
|
+
|
|
18
|
+
- `tex/__init__.py` (public exports)
|
|
19
|
+
- `tex/config.py` (endpoint paths + config)
|
|
20
|
+
- `tex/client.py` (client behavior, auth, retries, request/response handling)
|
|
21
|
+
- `tex/errors.py` (error types)
|
|
22
|
+
|
|
23
|
+
If you are reading this inside the repo, those files are the source of truth.
|
|
24
|
+
|
|
25
|
+
## What you get
|
|
26
|
+
|
|
27
|
+
- One client class: `Tex`
|
|
28
|
+
- Three auth modes (API key, org+user login, or direct access token)
|
|
29
|
+
- Minimal, predictable return values: most methods return JSON as `dict`
|
|
30
|
+
- Friendly NLQ output via `AskResult`
|
|
31
|
+
- Typed package (`tex/py.typed`) for mypy/pyright
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install tex-sdk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Optional: enable HTTP/2 (requires `h2`):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install "tex-sdk[http2]"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Python requirement (from `pyproject.toml`): Python 3.9+
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
Important: the SDK’s `base_url` must point at IntegrationBackend (the FastAPI gateway), not HelixDB directly.
|
|
50
|
+
|
|
51
|
+
### 1) API key auth (recommended)
|
|
52
|
+
|
|
53
|
+
This is the simplest and most “production-like” flow.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from tex import Tex
|
|
57
|
+
|
|
58
|
+
tx = Tex("http://localhost:8000", api_key="sk_live_...")
|
|
59
|
+
|
|
60
|
+
tx.store_memory(
|
|
61
|
+
"Hello from Tex SDK.",
|
|
62
|
+
type="document",
|
|
63
|
+
metadata={"source": "quickstart"},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
answer = tx.ask("What did I just store?")
|
|
67
|
+
print(answer.text)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### 2) Org + user login (dev / testing)
|
|
71
|
+
|
|
72
|
+
If your IntegrationBackend allows `/auth/login` for a given org+user, you can use:
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from tex import Tex
|
|
76
|
+
|
|
77
|
+
tx = Tex(
|
|
78
|
+
"http://localhost:8000",
|
|
79
|
+
org_id="org_123",
|
|
80
|
+
user_id="user_456",
|
|
81
|
+
session_id="s1", # optional
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
tx.store_memory("I like espresso.", type="document")
|
|
85
|
+
print(tx.ask("What do I like?").text)
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 3) Direct access token (bring-your-own JWT)
|
|
89
|
+
|
|
90
|
+
If you already obtained an access token (e.g., out-of-band), pass it directly:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from tex import Tex
|
|
94
|
+
|
|
95
|
+
tx = Tex("http://localhost:8000", access_token="eyJ...")
|
|
96
|
+
print(tx.whoami())
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## The public API surface
|
|
100
|
+
|
|
101
|
+
The package exports (see `tex/__init__.py`):
|
|
102
|
+
|
|
103
|
+
- `Tex`
|
|
104
|
+
- `AskResult`
|
|
105
|
+
- `TexConfig`, `TexEndpoints`
|
|
106
|
+
- `TexError`, `TexAuthError`, `TexHTTPError`
|
|
107
|
+
|
|
108
|
+
## Concepts
|
|
109
|
+
|
|
110
|
+
### Multi-tenant scope
|
|
111
|
+
|
|
112
|
+
IntegrationBackend is multi-tenant. Requests typically belong to a tenant:
|
|
113
|
+
|
|
114
|
+
- `org_id`
|
|
115
|
+
- `user_id`
|
|
116
|
+
- `session_id` (optional)
|
|
117
|
+
|
|
118
|
+
In the SDK, “scope” is included in ingestion payloads via `Tex._scope_payload()`. Internally:
|
|
119
|
+
|
|
120
|
+
- The backend ultimately trusts the JWT for tenant claims.
|
|
121
|
+
- The SDK still sends `scope` because the request models expect it.
|
|
122
|
+
- When using API keys, the SDK attempts to discover tenant claims by calling `GET /auth/verify`.
|
|
123
|
+
|
|
124
|
+
If tenant discovery fails, the SDK may send placeholders (`"_"`) for `org_id`/`user_id`; the backend is expected to ignore these and use the JWT tenant instead.
|
|
125
|
+
|
|
126
|
+
### Correlation IDs
|
|
127
|
+
|
|
128
|
+
Each request includes a unique `X-Correlation-ID` header generated per request (see `tex/client.py`).
|
|
129
|
+
|
|
130
|
+
- This is useful for tracing requests through your logs.
|
|
131
|
+
- When available, the SDK also exposes this as `request_id` on raised exceptions.
|
|
132
|
+
|
|
133
|
+
## Authentication: exact behavior
|
|
134
|
+
|
|
135
|
+
This section mirrors `Tex._ensure_auth()` and `Tex._refresh()` in `tex/client.py`.
|
|
136
|
+
|
|
137
|
+
### Auth modes (in priority order)
|
|
138
|
+
|
|
139
|
+
When the SDK needs auth, it chooses the first available:
|
|
140
|
+
|
|
141
|
+
1) If `access_token` is already present in memory → use it.
|
|
142
|
+
2) If `TexConfig.access_token` is provided → use it.
|
|
143
|
+
3) Else if `api_key` is provided:
|
|
144
|
+
- `POST /auth/token-exchange` with `{ "api_key": "..." }` → get `access_token` (+ optional `refresh_token`)
|
|
145
|
+
- `GET /auth/verify` to discover tenant claims
|
|
146
|
+
- If you also explicitly provided `user_id`, the SDK then calls `POST /auth/login` to mint a user-scoped token.
|
|
147
|
+
4) Else if `org_id` + `user_id` is provided:
|
|
148
|
+
- `POST /auth/login` with `{ org_id, user_id, session_id }`
|
|
149
|
+
5) Otherwise → raise `TexAuthError` (“No auth configured...”).
|
|
150
|
+
|
|
151
|
+
### Refresh + retry
|
|
152
|
+
|
|
153
|
+
All authenticated requests go through `_request()`.
|
|
154
|
+
|
|
155
|
+
- If a request returns HTTP 401:
|
|
156
|
+
- The SDK attempts `_refresh()` and retries the request once.
|
|
157
|
+
- Refresh rules:
|
|
158
|
+
- If `refresh_token` exists: `POST /auth/refresh`.
|
|
159
|
+
- Else if `api_key` exists: re-exchange the API key (and, if `user_id` was set, re-login).
|
|
160
|
+
- Otherwise: raise `TexAuthError`.
|
|
161
|
+
|
|
162
|
+
## Configuration
|
|
163
|
+
|
|
164
|
+
### `TexConfig`
|
|
165
|
+
|
|
166
|
+
You can construct the client with either:
|
|
167
|
+
|
|
168
|
+
- A `base_url` string: `Tex("http://localhost:8000", api_key=...)`
|
|
169
|
+
- A `TexConfig` object: `Tex(TexConfig(base_url=..., api_key=...))`
|
|
170
|
+
|
|
171
|
+
Fields (see `tex/config.py`):
|
|
172
|
+
|
|
173
|
+
- `base_url`: IntegrationBackend URL, e.g. `http://localhost:8000`
|
|
174
|
+
- Auth fields: `api_key`, `org_id`, `user_id`, `session_id`, `access_token`, `refresh_token`
|
|
175
|
+
- Transport: `timeout_s` (default 15s), `http2` (default True)
|
|
176
|
+
- `endpoints`: a `TexEndpoints` instance
|
|
177
|
+
|
|
178
|
+
### `TexEndpoints`
|
|
179
|
+
|
|
180
|
+
`TexEndpoints` contains the path strings the SDK calls (all relative to `base_url`).
|
|
181
|
+
|
|
182
|
+
Defaults (see `tex/config.py`):
|
|
183
|
+
|
|
184
|
+
- Auth:
|
|
185
|
+
- `auth_login`: `/auth/login`
|
|
186
|
+
- `auth_refresh`: `/auth/refresh`
|
|
187
|
+
- `auth_token_exchange`: `/auth/token-exchange`
|
|
188
|
+
- `auth_verify`: `/auth/verify`
|
|
189
|
+
- Ingestion:
|
|
190
|
+
- `ingestion_document`: `/ingestion/document`
|
|
191
|
+
- `ingestion_episode`: `/ingestion/episode`
|
|
192
|
+
- `ingestion_preference`: `/ingestion/preference`
|
|
193
|
+
- `ingestion_status`: `/ingestion/status/{job_id}`
|
|
194
|
+
- `ingestion_batch`: `/ingestion/batch`
|
|
195
|
+
- DB:
|
|
196
|
+
- `db_query`: `/helixdb/query`
|
|
197
|
+
- `db_schema`: `/helixdb/schema`
|
|
198
|
+
- NLQ: `nlq_execute`: `/nlq/execute`
|
|
199
|
+
- Search: `search`: `/search`
|
|
200
|
+
- Memories CRUD: `memories_list`, `memories_get`, `memories_delete`, `memories_update`
|
|
201
|
+
- Episodes: `episodes_list`: `/memories/episodes`
|
|
202
|
+
- Users: `user_profile`: `/users/profile`
|
|
203
|
+
|
|
204
|
+
If your deployment uses different routes/prefixes, override:
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from tex import Tex, TexConfig, TexEndpoints
|
|
208
|
+
|
|
209
|
+
endpoints = TexEndpoints(
|
|
210
|
+
# example override
|
|
211
|
+
db_query="/db/query",
|
|
212
|
+
db_schema="/db/schema",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
tx = Tex(TexConfig(base_url="https://api.example.com", api_key="sk_live_...", endpoints=endpoints))
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## API reference (method-by-method)
|
|
219
|
+
|
|
220
|
+
All examples assume:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
from tex import Tex
|
|
224
|
+
|
|
225
|
+
tx = Tex("http://localhost:8000", api_key="sk_live_...")
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### `Tex.store_memory(...)`
|
|
229
|
+
|
|
230
|
+
Single ingestion entrypoint. Behavior depends on `type`.
|
|
231
|
+
|
|
232
|
+
Signature (from `tex/client.py`):
|
|
233
|
+
|
|
234
|
+
- `content`: `str` or `list[dict]` (depending on `type`)
|
|
235
|
+
- `type`: one of `"document"`, `"episode"`, `"preference"`
|
|
236
|
+
- `format`: for documents, default `"text"`
|
|
237
|
+
- `metadata`: optional dict
|
|
238
|
+
- `options`: optional dict (passed through)
|
|
239
|
+
- `episode_id`: only for episode ingestion
|
|
240
|
+
|
|
241
|
+
#### Document ingestion (`type="document"`)
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
resp = tx.store_memory(
|
|
245
|
+
"A short document to ingest.",
|
|
246
|
+
type="document",
|
|
247
|
+
format="text",
|
|
248
|
+
metadata={"source": "docs"},
|
|
249
|
+
)
|
|
250
|
+
print(resp)
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Notes:
|
|
254
|
+
|
|
255
|
+
- For `type="document"`, `content` must be a string or the SDK raises `ValueError`.
|
|
256
|
+
- The request payload includes `scope` derived from your auth.
|
|
257
|
+
|
|
258
|
+
#### Episode ingestion (`type="episode"`)
|
|
259
|
+
|
|
260
|
+
Episodes represent chat-like messages.
|
|
261
|
+
|
|
262
|
+
You can pass a single string (it becomes one `{"role":"user","content":...}` message):
|
|
263
|
+
|
|
264
|
+
```python
|
|
265
|
+
resp = tx.store_memory(
|
|
266
|
+
"Today I called Alice and discussed the plan.",
|
|
267
|
+
type="episode",
|
|
268
|
+
)
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Or pass a message list:
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
messages = [
|
|
275
|
+
{"role": "user", "content": "Book a table for two."},
|
|
276
|
+
{"role": "assistant", "content": "Which restaurant?"},
|
|
277
|
+
{"role": "user", "content": "Somewhere near downtown."},
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
resp = tx.store_memory(messages, type="episode", episode_id="ep_001")
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
If you pass an invalid structure, the SDK raises `ValueError`.
|
|
284
|
+
|
|
285
|
+
#### Preference ingestion (`type="preference"`)
|
|
286
|
+
|
|
287
|
+
Preference ingestion expects preferences in `metadata["preferences"]`.
|
|
288
|
+
|
|
289
|
+
```python
|
|
290
|
+
resp = tx.store_memory(
|
|
291
|
+
"ignored",
|
|
292
|
+
type="preference",
|
|
293
|
+
metadata={
|
|
294
|
+
"preferences": [
|
|
295
|
+
{"key": "drink", "value": "espresso", "confidence": 0.9},
|
|
296
|
+
]
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Important:
|
|
302
|
+
|
|
303
|
+
- For `type="preference"`, the SDK ignores `content` and requires a non-empty list at `metadata["preferences"]`.
|
|
304
|
+
- The SDK sends `{ org_id, user_id, session_id, preferences }` using discovered scope.
|
|
305
|
+
|
|
306
|
+
### `Tex.job(job_id)`
|
|
307
|
+
|
|
308
|
+
Fetch background ingestion status.
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
status = tx.job("job_...")
|
|
312
|
+
print(status)
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
Internally calls `GET /ingestion/status/{job_id}`.
|
|
316
|
+
|
|
317
|
+
### `Tex.batch_store(documents)`
|
|
318
|
+
|
|
319
|
+
Ingest multiple documents in one call.
|
|
320
|
+
|
|
321
|
+
Each document dict should contain:
|
|
322
|
+
|
|
323
|
+
- `data` (str)
|
|
324
|
+
- optional `format`, `metadata`, `options`
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
resp = tx.batch_store(
|
|
328
|
+
[
|
|
329
|
+
{"data": "doc 1", "metadata": {"source": "batch"}},
|
|
330
|
+
{"data": "doc 2", "format": "text"},
|
|
331
|
+
]
|
|
332
|
+
)
|
|
333
|
+
print(resp)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Return shape depends on IntegrationBackend; the SDK returns the JSON response.
|
|
337
|
+
|
|
338
|
+
### `Tex.search(query, ...)`
|
|
339
|
+
|
|
340
|
+
Fast semantic search.
|
|
341
|
+
|
|
342
|
+
```python
|
|
343
|
+
resp = tx.search("espresso", top_k=5)
|
|
344
|
+
print(resp)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Optional parameters:
|
|
348
|
+
|
|
349
|
+
- `min_score`: float
|
|
350
|
+
- `label`: string label filter
|
|
351
|
+
- `metadata_filter`: dict
|
|
352
|
+
|
|
353
|
+
### `Tex.ask(question, ...)` → `AskResult`
|
|
354
|
+
|
|
355
|
+
Natural language query execution.
|
|
356
|
+
|
|
357
|
+
```python
|
|
358
|
+
ans = tx.ask("What did I store recently?")
|
|
359
|
+
print(ans.text)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Return type is `AskResult`:
|
|
363
|
+
|
|
364
|
+
- `text`: human-readable summary assembled from evidence/bindings/documents
|
|
365
|
+
- `evidence`: list of evidence strings
|
|
366
|
+
- `entities`: list of extracted/bound entity names
|
|
367
|
+
- `documents`: list of document identifiers
|
|
368
|
+
- `raw`: the full JSON response dict
|
|
369
|
+
|
|
370
|
+
Parameters (passed through to `/nlq/execute`):
|
|
371
|
+
|
|
372
|
+
- `execute` (default True)
|
|
373
|
+
- `enable_pruning` (default True)
|
|
374
|
+
- `use_local_intelligence` (default True)
|
|
375
|
+
- `intent_match_options` (optional dict)
|
|
376
|
+
- `compact` (default True)
|
|
377
|
+
|
|
378
|
+
### `Tex.query(query, params=None)`
|
|
379
|
+
|
|
380
|
+
Execute a DB query via IntegrationBackend.
|
|
381
|
+
|
|
382
|
+
```python
|
|
383
|
+
resp = tx.query(
|
|
384
|
+
"MATCH (n) RETURN n LIMIT $limit",
|
|
385
|
+
params={"limit": 5},
|
|
386
|
+
)
|
|
387
|
+
print(resp)
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
This calls `POST /helixdb/query` by default (see `TexEndpoints.db_query`).
|
|
391
|
+
|
|
392
|
+
### `Tex.schema()`
|
|
393
|
+
|
|
394
|
+
Fetch the database schema.
|
|
395
|
+
|
|
396
|
+
```python
|
|
397
|
+
schema = tx.schema()
|
|
398
|
+
print(schema)
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
This calls `GET /helixdb/schema` by default.
|
|
402
|
+
|
|
403
|
+
### `Tex.whoami()`
|
|
404
|
+
|
|
405
|
+
Returns the tenant context as seen by IntegrationBackend (great for debugging auth/scope).
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
print(tx.whoami())
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Internally, it ensures auth, then calls `GET /auth/verify` (unless already cached).
|
|
412
|
+
|
|
413
|
+
### Memories CRUD
|
|
414
|
+
|
|
415
|
+
These map to `/memories` endpoints.
|
|
416
|
+
|
|
417
|
+
#### `Tex.get_memory(memory_id)`
|
|
418
|
+
|
|
419
|
+
```python
|
|
420
|
+
mem = tx.get_memory("mem_...")
|
|
421
|
+
print(mem)
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
#### `Tex.list_memories(type=None, limit=50, offset=0)`
|
|
425
|
+
|
|
426
|
+
```python
|
|
427
|
+
resp = tx.list_memories(limit=20, offset=0)
|
|
428
|
+
print(resp)
|
|
429
|
+
|
|
430
|
+
docs = tx.list_memories(type="document", limit=20)
|
|
431
|
+
print(docs)
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
#### `Tex.update_memory(memory_id, content=None, metadata=None, options=None)`
|
|
435
|
+
|
|
436
|
+
```python
|
|
437
|
+
resp = tx.update_memory(
|
|
438
|
+
"mem_...",
|
|
439
|
+
content="updated content",
|
|
440
|
+
metadata={"tag": "updated"},
|
|
441
|
+
)
|
|
442
|
+
print(resp)
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
#### `Tex.delete_memory(memory_id)`
|
|
446
|
+
|
|
447
|
+
```python
|
|
448
|
+
resp = tx.delete_memory("mem_...")
|
|
449
|
+
print(resp)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
#### `Tex.delete_memories(user_id=None)`
|
|
453
|
+
|
|
454
|
+
Bulk delete.
|
|
455
|
+
|
|
456
|
+
- If `user_id` is omitted, deletes the caller’s memories.
|
|
457
|
+
- If `user_id` is provided and differs from caller, IntegrationBackend may require elevated roles.
|
|
458
|
+
|
|
459
|
+
```python
|
|
460
|
+
resp = tx.delete_memories()
|
|
461
|
+
print(resp)
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Episodes
|
|
465
|
+
|
|
466
|
+
#### `Tex.list_episodes(limit=50, offset=0, since=None)`
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
resp = tx.list_episodes(limit=10)
|
|
470
|
+
print(resp)
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### User profile
|
|
474
|
+
|
|
475
|
+
#### `Tex.get_profile(format="text")`
|
|
476
|
+
|
|
477
|
+
Returns a synthesized profile derived from preferences and episodic memories.
|
|
478
|
+
|
|
479
|
+
```python
|
|
480
|
+
profile = tx.get_profile(format="text")
|
|
481
|
+
print(profile)
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## Errors and exception handling
|
|
485
|
+
|
|
486
|
+
Errors are defined in `tex/errors.py`.
|
|
487
|
+
|
|
488
|
+
### `TexHTTPError`
|
|
489
|
+
|
|
490
|
+
Raised for non-auth HTTP failures (status >= 400 excluding 401/403) and network/timeout errors.
|
|
491
|
+
|
|
492
|
+
Fields:
|
|
493
|
+
|
|
494
|
+
- `message` (str)
|
|
495
|
+
- `status_code` (optional int)
|
|
496
|
+
- `request_id` (optional str; pulled from `X-Correlation-ID` response header if present)
|
|
497
|
+
- `response_text` (optional str; truncated to 2000 chars)
|
|
498
|
+
- `details` (any; parsed JSON when possible)
|
|
499
|
+
|
|
500
|
+
### `TexAuthError`
|
|
501
|
+
|
|
502
|
+
Subclass of `TexHTTPError`, raised for auth issues:
|
|
503
|
+
|
|
504
|
+
- “No auth configured”
|
|
505
|
+
- token exchange/login failures
|
|
506
|
+
- HTTP 401/403 responses
|
|
507
|
+
|
|
508
|
+
### Typical pattern
|
|
509
|
+
|
|
510
|
+
```python
|
|
511
|
+
from tex import Tex, TexAuthError, TexHTTPError
|
|
512
|
+
|
|
513
|
+
tx = Tex("http://localhost:8000", api_key="sk_live_...")
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
print(tx.whoami())
|
|
517
|
+
except TexAuthError as e:
|
|
518
|
+
# Wrong key, missing roles, expired/invalid refresh, etc.
|
|
519
|
+
print("auth failed", str(e), e.status_code)
|
|
520
|
+
except TexHTTPError as e:
|
|
521
|
+
# Non-auth HTTP errors (422, 500, network issues)
|
|
522
|
+
print("request failed", str(e), e.status_code)
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
## Request/response handling details
|
|
526
|
+
|
|
527
|
+
This mirrors `Tex._request()` and `_post_noauth()`.
|
|
528
|
+
|
|
529
|
+
- JSON responses are returned as Python dicts.
|
|
530
|
+
- If the response body is valid JSON but not a dict (e.g., a list), the SDK wraps it as `{ "data": <payload> }`.
|
|
531
|
+
- If the response is not JSON, the SDK returns `{ "data": "<text>" }`.
|
|
532
|
+
- For error responses, the SDK tries to extract `detail` or `message` from JSON. Otherwise: “Request failed”.
|
|
533
|
+
|
|
534
|
+
Special hint for HTTP 422:
|
|
535
|
+
|
|
536
|
+
- If the error mentions “IntentGraph validation” / “root variable must have a type”, the SDK appends a hint suggesting schema/planner issues.
|
|
537
|
+
|
|
538
|
+
## Running the smoketest script (repo)
|
|
539
|
+
|
|
540
|
+
The repo includes a minimal end-to-end tester:
|
|
541
|
+
|
|
542
|
+
- `tools/tex_sdk_smoketest.py`
|
|
543
|
+
|
|
544
|
+
It is designed to work:
|
|
545
|
+
|
|
546
|
+
- When installed from PyPI (`pip install tex-sdk`)
|
|
547
|
+
- When run directly from this repo (it falls back to adding the repo root to `sys.path`)
|
|
548
|
+
|
|
549
|
+
### Environment variables
|
|
550
|
+
|
|
551
|
+
- `TEX_BASE_URL` (default: `http://localhost:8000`)
|
|
552
|
+
- `TEX_TIMEOUT_S` (default: `30`)
|
|
553
|
+
|
|
554
|
+
Auth (set exactly one of the following groups):
|
|
555
|
+
|
|
556
|
+
1) API key:
|
|
557
|
+
|
|
558
|
+
- `TEX_API_KEY=sk_live_...`
|
|
559
|
+
|
|
560
|
+
2) Org+user login:
|
|
561
|
+
|
|
562
|
+
- `TEX_ORG_ID=...`
|
|
563
|
+
- `TEX_USER_ID=...`
|
|
564
|
+
- `TEX_SESSION_ID=...` (optional)
|
|
565
|
+
|
|
566
|
+
3) Direct token:
|
|
567
|
+
|
|
568
|
+
- `TEX_ACCESS_TOKEN=eyJ...`
|
|
569
|
+
|
|
570
|
+
### Run
|
|
571
|
+
|
|
572
|
+
```powershell
|
|
573
|
+
$env:TEX_BASE_URL = "http://localhost:8000"
|
|
574
|
+
$env:TEX_API_KEY = "sk_live_..."
|
|
575
|
+
|
|
576
|
+
python tools/tex_sdk_smoketest.py
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
The script performs:
|
|
580
|
+
|
|
581
|
+
- `GET /health` (unauth quick check)
|
|
582
|
+
- `whoami()`
|
|
583
|
+
- `store_memory(type="document")`
|
|
584
|
+
- polls `job(job_id)` (if job_id returned)
|
|
585
|
+
- `search(...)`
|
|
586
|
+
- `ask(...)`
|
|
587
|
+
|
|
588
|
+
## Migration notes (in-repo users)
|
|
589
|
+
|
|
590
|
+
Inside this repo, there is also an `sdk/` package that re-exports everything from `tex/` as a compatibility shim.
|
|
591
|
+
|
|
592
|
+
- New code should import from `tex`.
|
|
593
|
+
- Old code importing `sdk` will continue to work (see `sdk/__init__.py`).
|
|
594
|
+
|
|
595
|
+
## Troubleshooting
|
|
596
|
+
|
|
597
|
+
### “Connection refused” / health check fails
|
|
598
|
+
|
|
599
|
+
- Ensure IntegrationBackend is running (default: `http://localhost:8000/health`).
|
|
600
|
+
- The SDK does not start services; it only calls HTTP endpoints.
|
|
601
|
+
|
|
602
|
+
### HTTP 401 / 403
|
|
603
|
+
|
|
604
|
+
- Verify you’re using the correct auth mode.
|
|
605
|
+
- For API key auth: ensure the key is valid and belongs to the tenant you expect.
|
|
606
|
+
- For org+user login: ensure `/auth/login` is enabled for that org/user.
|
|
607
|
+
|
|
608
|
+
### HTTP 422 (validation errors)
|
|
609
|
+
|
|
610
|
+
- Usually means your request payload doesn’t match backend expectations.
|
|
611
|
+
- For NLQ/planner-related validation errors, confirm the DB schema is available and the planner can fetch it.
|
|
612
|
+
|
|
613
|
+
### HTTP/2 import error (`h2`)
|
|
614
|
+
|
|
615
|
+
If you see an ImportError mentioning `h2`, install:
|
|
616
|
+
|
|
617
|
+
```bash
|
|
618
|
+
pip install "tex-sdk[http2]"
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
Or disable HTTP/2:
|
|
622
|
+
|
|
623
|
+
```python
|
|
624
|
+
from tex import Tex
|
|
625
|
+
|
|
626
|
+
tx = Tex("http://localhost:8000", api_key="sk_live_...", http2=False)
|
|
627
|
+
```
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
tex/__init__.py,sha256=ZqTfS02rhX7tzl2k6zNb5gZskYdZ-gDMuS6D6KysvtY,415
|
|
2
|
+
tex/client.py,sha256=1kAUK99Qi6zcPklKZIubV8oAhLr4JDvEC7T50SZIyjs,25340
|
|
3
|
+
tex/config.py,sha256=AvPyt_WEfPOZDpSryNsF9GFSN0O2qDFD23j6LBj95dc,1938
|
|
4
|
+
tex/errors.py,sha256=NlE63BYFPnukvrZufmrSU63k9HV61wcL_U0ykRbSHgA,733
|
|
5
|
+
tex/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
tex_sdk-0.3.0.dist-info/METADATA,sha256=Y8YROV55tlLA2ErCHVzH1dKsI33FFnIV63PSSs7xVy8,16178
|
|
7
|
+
tex_sdk-0.3.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
8
|
+
tex_sdk-0.3.0.dist-info/top_level.txt,sha256=-u3o-lc05yHetpTLGtM13couqCEEb_mzuJMXYFS5SZs,4
|
|
9
|
+
tex_sdk-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tex
|