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.
- forktex_intelligence/__init__.py +97 -0
- forktex_intelligence/api.py +544 -0
- forktex_intelligence/client/__init__.py +56 -0
- forktex_intelligence/client/client.py +360 -0
- forktex_intelligence/client/generated/__init__.py +623 -0
- forktex_intelligence/config.py +43 -0
- forktex_intelligence/streams.py +152 -0
- forktex_intelligence-0.2.3.dist-info/METADATA +178 -0
- forktex_intelligence-0.2.3.dist-info/RECORD +12 -0
- forktex_intelligence-0.2.3.dist-info/WHEEL +4 -0
- forktex_intelligence-0.2.3.dist-info/licenses/LICENSE +45 -0
- forktex_intelligence-0.2.3.dist-info/licenses/NOTICE +22 -0
|
@@ -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)
|