forktex-intelligence 0.2.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-intelligence.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """ForkTex Intelligence async client.
25
+
26
+ Combines a generated method-per-operation surface (``_GeneratedOperations``,
27
+ emitted from ``openapi.json``) with a handwritten patch layer providing
28
+ session lifecycle, auth header management (dual JWT / X-API-Key), error
29
+ normalization, and wrappers for the request shapes the generator cannot
30
+ express verbatim: SSE streaming and ``multipart/form-data`` uploads.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from typing import Any, AsyncIterator, Dict
36
+ from uuid import UUID
37
+
38
+ import httpx
39
+
40
+ from forktex_intelligence.config import IntelligenceSettings
41
+ from forktex_intelligence.client.generated import (
42
+ ChatMessage,
43
+ ChatRequest,
44
+ ChatResponse,
45
+ LoginRequest,
46
+ OrgCreateRequest,
47
+ OrgResponse,
48
+ RegisterRequest,
49
+ StructuredChatRequest,
50
+ StructuredChatResponse,
51
+ _GeneratedOperations,
52
+ )
53
+
54
+
55
+ class IntelligenceAPIError(Exception):
56
+ """Raised when the Intelligence API returns a non-2xx response."""
57
+
58
+ def __init__(self, status_code: int, detail: str) -> None:
59
+ self.status_code = status_code
60
+ self.detail = detail
61
+ super().__init__(f"HTTP {status_code}: {detail}")
62
+
63
+
64
+ class ForktexIntelligenceClient(_GeneratedOperations):
65
+ """Async client for the ForkTex Intelligence API.
66
+
67
+ Public surface is the union of:
68
+
69
+ * ~20 generated async methods inherited from ``_GeneratedOperations``
70
+ (``health``, ``list_models``, ``list_colls``, ``search_single`` …).
71
+ * The handwritten wrappers on this class for flows the generator does
72
+ not express: SSE chat streaming, multipart upload, JWT capture on
73
+ login/register, org-id capture on whoami / create_org.
74
+
75
+ Dual auth is supported:
76
+
77
+ * ``X-API-Key`` — org-scoped, no JWT needed (machine-to-machine).
78
+ * ``Authorization: Bearer <JWT>`` — user-level + org-scoped.
79
+
80
+ Org-scoped generated ops take ``org_id: UUID`` as the first argument;
81
+ the caller is expected to pass the currently-active org. This class
82
+ tracks one in ``self._org_id`` as a convenience for facade callers.
83
+ """
84
+
85
+ def __init__(
86
+ self,
87
+ base_url: str,
88
+ api_key: str = "",
89
+ *,
90
+ jwt_token: str = "",
91
+ org_id: str | UUID | None = None,
92
+ timeout: float = 120.0,
93
+ transport: httpx.AsyncBaseTransport | None = None,
94
+ ) -> None:
95
+ """Construct a client.
96
+
97
+ ``transport`` is an optional custom httpx transport. Pass an
98
+ ``httpx.ASGITransport(app=fastapi_app)`` to drive an in-process
99
+ FastAPI instance — used by contract tests to exercise the SDK
100
+ against a live ASGI app without a network hop.
101
+ """
102
+ # Generated operations emit absolute paths including ``/api/...``, so
103
+ # the httpx base_url must be the origin only. Strip a trailing
104
+ # ``/api`` from a configured endpoint for backwards compatibility.
105
+ trimmed = base_url.rstrip("/")
106
+ if trimmed.endswith("/api"):
107
+ trimmed = trimmed[: -len("/api")]
108
+ self._base_url = trimmed
109
+ self._api_key = api_key
110
+ self._jwt_token = jwt_token
111
+ self._org_id = str(org_id) if org_id else ""
112
+
113
+ headers: Dict[str, str] = {}
114
+ if api_key:
115
+ headers["X-API-Key"] = api_key
116
+ if jwt_token:
117
+ headers["Authorization"] = f"Bearer {jwt_token}"
118
+
119
+ client_kwargs: Dict[str, Any] = dict(
120
+ base_url=self._base_url,
121
+ headers=headers,
122
+ timeout=timeout,
123
+ )
124
+ if transport is not None:
125
+ client_kwargs["transport"] = transport
126
+
127
+ self._client = httpx.AsyncClient(**client_kwargs)
128
+
129
+ @classmethod
130
+ def from_settings(cls, settings: IntelligenceSettings, **kwargs: Any) -> ForktexIntelligenceClient:
131
+ """Create a client from IntelligenceSettings."""
132
+ if not settings.is_configured:
133
+ raise RuntimeError("Intelligence API not configured. Run: forktex intelligence init")
134
+ return cls(settings.endpoint, settings.api_key, **kwargs)
135
+
136
+ def set_org(self, org_id: str | UUID) -> None:
137
+ """Set the org for org-scoped requests."""
138
+ self._org_id = str(org_id)
139
+
140
+ @property
141
+ def org_id(self) -> str:
142
+ """Currently-set org id, or empty string if not yet resolved."""
143
+ return self._org_id
144
+
145
+ @property
146
+ def api_key(self) -> str:
147
+ """Currently-configured API key (X-API-Key auth)."""
148
+ return self._api_key
149
+
150
+ @property
151
+ def base_url(self) -> str:
152
+ """Configured API base URL (no trailing slash, origin only)."""
153
+ return self._base_url
154
+
155
+ def set_jwt(self, token: str) -> None:
156
+ """Set JWT token for user-level auth."""
157
+ self._jwt_token = token
158
+ self._client.headers["Authorization"] = f"Bearer {token}"
159
+
160
+ async def close(self) -> None:
161
+ await self._client.aclose()
162
+
163
+ async def __aenter__(self) -> ForktexIntelligenceClient:
164
+ return self
165
+
166
+ async def __aexit__(self, *args: Any) -> None:
167
+ await self.close()
168
+
169
+ # ── Request infrastructure ────────────────────────────────────────
170
+
171
+ async def _request(
172
+ self,
173
+ method: str,
174
+ path: str,
175
+ *,
176
+ params: dict[str, Any] | None = None,
177
+ json: Any = None,
178
+ ) -> Any:
179
+ resp = await self._client.request(method, path, json=json, params=params)
180
+ if resp.status_code >= 400:
181
+ try:
182
+ detail = resp.json().get("detail", resp.text)
183
+ except Exception:
184
+ detail = resp.text
185
+ raise IntelligenceAPIError(resp.status_code, str(detail))
186
+ if resp.status_code == 204 or not resp.content:
187
+ return None
188
+ return resp.json()
189
+
190
+ # ── JWT / org capture overrides ──────────────────────────────────
191
+
192
+ async def login(self, email: str, password: str) -> Dict[str, Any]: # type: ignore[override]
193
+ """Login with email/password; captures the returned JWT token."""
194
+ data = await self._request(
195
+ "POST",
196
+ "/api/auth/login",
197
+ json=LoginRequest(email=email, password=password).model_dump(mode="json", exclude_unset=True),
198
+ )
199
+ if isinstance(data, dict) and "access_token" in data:
200
+ self.set_jwt(data["access_token"])
201
+ return data or {}
202
+
203
+ async def register(self, email: str, password: str) -> Dict[str, Any]: # type: ignore[override]
204
+ """Register a new user; captures the returned JWT token."""
205
+ data = await self._request(
206
+ "POST",
207
+ "/api/auth/register",
208
+ json=RegisterRequest(email=email, password=password).model_dump(mode="json", exclude_unset=True),
209
+ )
210
+ if isinstance(data, dict) and "access_token" in data:
211
+ self.set_jwt(data["access_token"])
212
+ return data or {}
213
+
214
+ async def whoami(self) -> Dict[str, Any]: # type: ignore[override]
215
+ """Resolve API key → org_id. Auto-sets org on this client."""
216
+ data = await self._request("GET", "/api/whoami")
217
+ if isinstance(data, dict) and "org_id" in data:
218
+ self.set_org(data["org_id"])
219
+ return data or {}
220
+
221
+ async def create_org(self, name: str, slug: str) -> OrgResponse:
222
+ """Create an org; captures the returned id as the active org."""
223
+ req = OrgCreateRequest(name=name, slug=slug)
224
+ data = await self._request("POST", "/api/orgs", json=req.model_dump(mode="json", exclude_unset=True))
225
+ if isinstance(data, dict) and "id" in data:
226
+ self.set_org(data["id"])
227
+ return OrgResponse.model_validate(data)
228
+
229
+ # ── Streaming / multipart — not expressible by the generator ─────
230
+
231
+ async def chat_stream(
232
+ self,
233
+ org_id: str | UUID,
234
+ messages: list[Dict[str, str]],
235
+ *,
236
+ model: str | None = None,
237
+ system: str | None = None,
238
+ temperature: float | None = None,
239
+ max_tokens: int | None = None,
240
+ tools: list[Dict[str, Any]] | None = None,
241
+ ) -> AsyncIterator[bytes]:
242
+ """SSE chat streaming. ``model``, ``system``, etc. forwarded verbatim."""
243
+ req = ChatRequest(
244
+ messages=[ChatMessage(**m) for m in messages],
245
+ model=model,
246
+ system=system,
247
+ temperature=temperature,
248
+ max_tokens=max_tokens,
249
+ stream=True,
250
+ tools=tools,
251
+ )
252
+ lines: list[bytes] = []
253
+ async with self._client.stream(
254
+ "POST",
255
+ f"/api/org/{org_id}/chat",
256
+ content=req.model_dump_json(exclude_none=True),
257
+ headers={
258
+ "Accept": "text/event-stream",
259
+ "Content-Type": "application/json",
260
+ },
261
+ ) as resp:
262
+ if resp.status_code >= 400:
263
+ raise IntelligenceAPIError(resp.status_code, await resp.aread())
264
+ async for line in resp.aiter_lines():
265
+ lines.append(line.encode())
266
+ for line in lines:
267
+ yield line
268
+
269
+ async def extract_file(
270
+ self,
271
+ org_id: str | UUID,
272
+ file_data: bytes,
273
+ filename: str,
274
+ *,
275
+ content_type: str = "application/octet-stream",
276
+ chunk_size: int = 256,
277
+ chunk_overlap: int = 32,
278
+ ) -> Dict[str, Any]:
279
+ """Upload a file for text extraction (stateless, no storage)."""
280
+ files = {"file": (filename, file_data, content_type)}
281
+ data = {
282
+ "chunk_size": str(chunk_size),
283
+ "chunk_overlap": str(chunk_overlap),
284
+ }
285
+ resp = await self._client.post(f"/api/org/{org_id}/extract", files=files, data=data)
286
+ if resp.status_code >= 400:
287
+ raise IntelligenceAPIError(resp.status_code, resp.text)
288
+ return resp.json()
289
+
290
+ async def upload_document(
291
+ self,
292
+ org_id: str | UUID,
293
+ collection_id: str | UUID,
294
+ file_data: bytes,
295
+ filename: str,
296
+ *,
297
+ content_type: str = "application/octet-stream",
298
+ ) -> Dict[str, Any]:
299
+ """Upload a document into a collection for ingestion (multipart)."""
300
+ files = {"file": (filename, file_data, content_type)}
301
+ resp = await self._client.post(f"/api/org/{org_id}/collections/{collection_id}/documents", files=files)
302
+ if resp.status_code >= 400:
303
+ raise IntelligenceAPIError(resp.status_code, resp.text)
304
+ return resp.json()
305
+
306
+ # ── Chat (handwritten — request shape dual-purposed across stream/non-stream) ─
307
+
308
+ async def chat( # type: ignore[override]
309
+ self,
310
+ org_id: str | UUID,
311
+ messages: list[Dict[str, str]],
312
+ *,
313
+ model: str | None = None,
314
+ system: str | None = None,
315
+ temperature: float | None = None,
316
+ max_tokens: int | None = None,
317
+ tools: list[Dict[str, Any]] | None = None,
318
+ ) -> ChatResponse:
319
+ """Non-streaming chat. Generator emits a body-only variant; this
320
+ convenience form accepts loose message dicts and constructs the
321
+ Pydantic request so ``chat`` and ``chat_stream`` share a call shape.
322
+ """
323
+ req = ChatRequest(
324
+ messages=[ChatMessage(**m) for m in messages],
325
+ model=model,
326
+ system=system,
327
+ temperature=temperature,
328
+ max_tokens=max_tokens,
329
+ stream=False,
330
+ tools=tools,
331
+ )
332
+ data = await self._request(
333
+ "POST",
334
+ f"/api/org/{org_id}/chat",
335
+ json=req.model_dump(mode="json", exclude_none=True),
336
+ )
337
+ return ChatResponse.model_validate(data)
338
+
339
+ async def chat_structured( # type: ignore[override]
340
+ self,
341
+ org_id: str | UUID,
342
+ messages: list[Dict[str, str]],
343
+ *,
344
+ model: str | None = None,
345
+ system: str | None = None,
346
+ response_schema: Dict[str, Any] | None = None,
347
+ ) -> StructuredChatResponse:
348
+ """Structured chat with a JSON schema."""
349
+ req = StructuredChatRequest(
350
+ messages=[ChatMessage(**m) for m in messages],
351
+ model=model,
352
+ system=system,
353
+ response_schema=response_schema,
354
+ )
355
+ data = await self._request(
356
+ "POST",
357
+ f"/api/org/{org_id}/chat/structured",
358
+ json=req.model_dump(mode="json", exclude_none=True),
359
+ )
360
+ return StructuredChatResponse.model_validate(data)