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 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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tex