pluginai 1.0.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.
- pluginai/__init__.py +54 -0
- pluginai/client.py +347 -0
- pluginai/exceptions.py +96 -0
- pluginai/models.py +118 -0
- pluginai/utils.py +116 -0
- pluginai-1.0.0.dist-info/METADATA +231 -0
- pluginai-1.0.0.dist-info/RECORD +10 -0
- pluginai-1.0.0.dist-info/WHEEL +5 -0
- pluginai-1.0.0.dist-info/licenses/LICENSE +21 -0
- pluginai-1.0.0.dist-info/top_level.txt +1 -0
pluginai/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""PluginAI -- Official Python SDK for the PluginAI RAG platform.
|
|
2
|
+
|
|
3
|
+
Quickstart::
|
|
4
|
+
|
|
5
|
+
from pluginai import PluginAI
|
|
6
|
+
|
|
7
|
+
client = PluginAI(
|
|
8
|
+
api_key="your-api-key",
|
|
9
|
+
workspace="your-workspace",
|
|
10
|
+
)
|
|
11
|
+
response = client.query("What is the refund policy?")
|
|
12
|
+
print(response.answer)
|
|
13
|
+
|
|
14
|
+
See https://pluginai.space for documentation.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from .client import PluginAI
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
AuthenticationError,
|
|
22
|
+
ConnectionError,
|
|
23
|
+
NoDataError,
|
|
24
|
+
PluginAIError,
|
|
25
|
+
QuotaExceededError,
|
|
26
|
+
RateLimitError,
|
|
27
|
+
ServerError,
|
|
28
|
+
TimeoutError,
|
|
29
|
+
WorkspaceNotFoundError,
|
|
30
|
+
)
|
|
31
|
+
from .models import Conversation, ConversationMessage, QueryResponse
|
|
32
|
+
|
|
33
|
+
__version__ = "1.0.0"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# Client
|
|
37
|
+
"PluginAI",
|
|
38
|
+
# Models
|
|
39
|
+
"QueryResponse",
|
|
40
|
+
"Conversation",
|
|
41
|
+
"ConversationMessage",
|
|
42
|
+
# Exceptions
|
|
43
|
+
"PluginAIError",
|
|
44
|
+
"AuthenticationError",
|
|
45
|
+
"QuotaExceededError",
|
|
46
|
+
"WorkspaceNotFoundError",
|
|
47
|
+
"NoDataError",
|
|
48
|
+
"ConnectionError",
|
|
49
|
+
"TimeoutError",
|
|
50
|
+
"RateLimitError",
|
|
51
|
+
"ServerError",
|
|
52
|
+
# Metadata
|
|
53
|
+
"__version__",
|
|
54
|
+
]
|
pluginai/client.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""The main :class:`PluginAI` client.
|
|
2
|
+
|
|
3
|
+
This is the entry point developers use to talk to a PluginAI backend::
|
|
4
|
+
|
|
5
|
+
from pluginai import PluginAI
|
|
6
|
+
|
|
7
|
+
client = PluginAI(
|
|
8
|
+
api_key="your-api-key",
|
|
9
|
+
workspace="your-workspace",
|
|
10
|
+
)
|
|
11
|
+
response = client.query("What is the refund policy?")
|
|
12
|
+
print(response.answer)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Callable, Dict, Optional
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
from requests.adapters import HTTPAdapter
|
|
21
|
+
|
|
22
|
+
try: # urllib3 ships with requests; Retry moved namespaces across versions.
|
|
23
|
+
from urllib3.util.retry import Retry
|
|
24
|
+
except ImportError: # pragma: no cover - very old urllib3
|
|
25
|
+
from requests.packages.urllib3.util.retry import Retry # type: ignore
|
|
26
|
+
|
|
27
|
+
from .exceptions import (
|
|
28
|
+
AuthenticationError,
|
|
29
|
+
ConnectionError,
|
|
30
|
+
NoDataError,
|
|
31
|
+
PluginAIError,
|
|
32
|
+
QuotaExceededError,
|
|
33
|
+
RateLimitError,
|
|
34
|
+
ServerError,
|
|
35
|
+
TimeoutError,
|
|
36
|
+
WorkspaceNotFoundError,
|
|
37
|
+
)
|
|
38
|
+
from .models import QueryResponse
|
|
39
|
+
from .utils import (
|
|
40
|
+
generate_conversation_id,
|
|
41
|
+
validate_api_key,
|
|
42
|
+
validate_query,
|
|
43
|
+
validate_workspace,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
__version__ = "1.0.0"
|
|
47
|
+
|
|
48
|
+
# Sent on every request so the backend can identify SDK traffic.
|
|
49
|
+
USER_AGENT = f"pluginai-python/{__version__}"
|
|
50
|
+
|
|
51
|
+
# Server-side statuses that are safe to retry (transient gateway failures only).
|
|
52
|
+
_RETRY_STATUSES = (502, 503, 504)
|
|
53
|
+
|
|
54
|
+
# Backend endpoints, appended to ``base_url``.
|
|
55
|
+
_QUERY_PATH = "/call/user/query/user_api_query"
|
|
56
|
+
_AGENT_QUERY_PATH = "/call/user/query/agent_query"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class PluginAI:
|
|
60
|
+
"""A synchronous client for the PluginAI Query API.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
api_key: Your workspace API key. Required.
|
|
64
|
+
workspace: The workspace name to query. Required.
|
|
65
|
+
base_url: Base URL of the PluginAI backend. Defaults to the hosted
|
|
66
|
+
PluginAI API; only override it if you self-host. A trailing slash is
|
|
67
|
+
stripped.
|
|
68
|
+
conversation_id: Continue an existing conversation by passing its id.
|
|
69
|
+
When omitted, a fresh id is generated automatically.
|
|
70
|
+
timeout: Per-request timeout in seconds (default 30).
|
|
71
|
+
max_retries: Number of retries on transient server errors (502/503/504)
|
|
72
|
+
only -- 4xx responses are never retried (default 2).
|
|
73
|
+
on_error: Optional callback invoked with the exception instance right
|
|
74
|
+
before any error is raised. Handy for centralised logging/metrics.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If ``api_key`` or ``workspace`` is missing or invalid.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
api_key: str,
|
|
83
|
+
workspace: str,
|
|
84
|
+
base_url: str = "https://api.pluginai.space",
|
|
85
|
+
conversation_id: Optional[str] = None,
|
|
86
|
+
timeout: int = 30,
|
|
87
|
+
max_retries: int = 2,
|
|
88
|
+
on_error: Optional[Callable[[PluginAIError], None]] = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
validate_api_key(api_key)
|
|
91
|
+
validate_workspace(workspace)
|
|
92
|
+
|
|
93
|
+
self.api_key = api_key
|
|
94
|
+
self.workspace = workspace
|
|
95
|
+
self.base_url = base_url.rstrip("/")
|
|
96
|
+
self.conversation_id = conversation_id or generate_conversation_id()
|
|
97
|
+
self.timeout = timeout
|
|
98
|
+
self.max_retries = max_retries
|
|
99
|
+
self.on_error = on_error
|
|
100
|
+
|
|
101
|
+
self._session = self._build_session(max_retries)
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------ #
|
|
104
|
+
# Session setup
|
|
105
|
+
# ------------------------------------------------------------------ #
|
|
106
|
+
def _build_session(self, max_retries: int) -> requests.Session:
|
|
107
|
+
"""Create a pooled :class:`requests.Session` with a retry policy."""
|
|
108
|
+
session = requests.Session()
|
|
109
|
+
session.headers.update(
|
|
110
|
+
{
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
"User-Agent": USER_AGENT,
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Only retry the transient gateway statuses -- never 4xx. ``total`` is
|
|
117
|
+
# driven by ``max_retries`` so callers can tune (or disable) retries.
|
|
118
|
+
retry = Retry(
|
|
119
|
+
total=max_retries,
|
|
120
|
+
status_forcelist=list(_RETRY_STATUSES),
|
|
121
|
+
allowed_methods=frozenset(["POST"]),
|
|
122
|
+
backoff_factor=0.5,
|
|
123
|
+
raise_on_status=False,
|
|
124
|
+
)
|
|
125
|
+
adapter = HTTPAdapter(max_retries=retry)
|
|
126
|
+
session.mount("http://", adapter)
|
|
127
|
+
session.mount("https://", adapter)
|
|
128
|
+
return session
|
|
129
|
+
|
|
130
|
+
# ------------------------------------------------------------------ #
|
|
131
|
+
# Public API
|
|
132
|
+
# ------------------------------------------------------------------ #
|
|
133
|
+
def query(self, message: str) -> QueryResponse:
|
|
134
|
+
"""Send a question and return a grounded :class:`QueryResponse`.
|
|
135
|
+
|
|
136
|
+
Uses the standard retrieval pipeline. For complex, multi-step questions
|
|
137
|
+
see :meth:`agent_query`.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
message: The natural-language question to ask.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
A :class:`~pluginai.QueryResponse` with the answer, status and timing.
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
ValueError: If ``message`` is empty, whitespace-only or too long.
|
|
147
|
+
NoDataError: If the workspace has no relevant/indexed documents.
|
|
148
|
+
AuthenticationError: On an invalid or inactive API key (401/403).
|
|
149
|
+
QuotaExceededError: When a usage quota/limit has been hit (403).
|
|
150
|
+
WorkspaceNotFoundError: If the workspace does not exist (404).
|
|
151
|
+
RateLimitError: When too many requests are sent (429).
|
|
152
|
+
ServerError: On a backend error (5xx).
|
|
153
|
+
TimeoutError: If the request exceeds ``timeout``.
|
|
154
|
+
ConnectionError: If the backend cannot be reached.
|
|
155
|
+
PluginAIError: For any other SDK-level failure.
|
|
156
|
+
"""
|
|
157
|
+
return self._do_query(self._QUERY_URL, message)
|
|
158
|
+
|
|
159
|
+
def agent_query(self, message: str) -> QueryResponse:
|
|
160
|
+
"""Send a question through the Agentic RAG pipeline.
|
|
161
|
+
|
|
162
|
+
Behaves exactly like :meth:`query` -- same arguments, return type and
|
|
163
|
+
errors -- but routes to the agent endpoint, which performs multi-step
|
|
164
|
+
reasoning and tool use for complex questions. It may take longer than a
|
|
165
|
+
plain :meth:`query`, so consider raising ``timeout`` accordingly.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
message: The natural-language question to ask.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
A :class:`~pluginai.QueryResponse` with the answer, status and timing.
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
Same exceptions as :meth:`query`.
|
|
175
|
+
"""
|
|
176
|
+
return self._do_query(self._AGENT_QUERY_URL, message)
|
|
177
|
+
|
|
178
|
+
def reset_conversation(self) -> str:
|
|
179
|
+
"""Start a new conversation and return the new id.
|
|
180
|
+
|
|
181
|
+
Generates a fresh ``conversation_id``; every subsequent :meth:`query`
|
|
182
|
+
and :meth:`agent_query` call is grouped under it.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
The newly generated conversation id.
|
|
186
|
+
"""
|
|
187
|
+
self.conversation_id = generate_conversation_id()
|
|
188
|
+
return self.conversation_id
|
|
189
|
+
|
|
190
|
+
def close(self) -> None:
|
|
191
|
+
"""Close the underlying HTTP session and free pooled connections."""
|
|
192
|
+
self._session.close()
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------ #
|
|
195
|
+
# Internal request/response handling
|
|
196
|
+
# ------------------------------------------------------------------ #
|
|
197
|
+
@property
|
|
198
|
+
def _QUERY_URL(self) -> str:
|
|
199
|
+
return f"{self.base_url}{_QUERY_PATH}"
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def _AGENT_QUERY_URL(self) -> str:
|
|
203
|
+
return f"{self.base_url}{_AGENT_QUERY_PATH}"
|
|
204
|
+
|
|
205
|
+
def _do_query(self, url: str, message: str) -> QueryResponse:
|
|
206
|
+
"""Validate, POST, and translate the response into a model or error."""
|
|
207
|
+
validate_query(message)
|
|
208
|
+
|
|
209
|
+
payload: Dict[str, Any] = {
|
|
210
|
+
"query": message,
|
|
211
|
+
"workspace_name": self.workspace,
|
|
212
|
+
"Api_key": self.api_key,
|
|
213
|
+
"unique_id": self.conversation_id,
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
response = self._session.post(url, json=payload, timeout=self.timeout)
|
|
218
|
+
except requests.Timeout as exc:
|
|
219
|
+
raise self._fail(
|
|
220
|
+
TimeoutError(
|
|
221
|
+
f"Request timed out after {self.timeout}s. Try increasing "
|
|
222
|
+
"the `timeout` parameter.",
|
|
223
|
+
status_code=None,
|
|
224
|
+
)
|
|
225
|
+
) from exc
|
|
226
|
+
except requests.ConnectionError as exc:
|
|
227
|
+
raise self._fail(
|
|
228
|
+
ConnectionError(
|
|
229
|
+
f"Could not reach the PluginAI backend at {self.base_url}. "
|
|
230
|
+
"Check the `base_url` and your network connection.",
|
|
231
|
+
status_code=None,
|
|
232
|
+
)
|
|
233
|
+
) from exc
|
|
234
|
+
except requests.RequestException as exc:
|
|
235
|
+
raise self._fail(
|
|
236
|
+
PluginAIError(f"Request failed: {exc}", status_code=None)
|
|
237
|
+
) from exc
|
|
238
|
+
|
|
239
|
+
if response.status_code == 200:
|
|
240
|
+
return self._parse_success(response)
|
|
241
|
+
|
|
242
|
+
raise self._raise_for_status(response)
|
|
243
|
+
|
|
244
|
+
def _parse_success(self, response: requests.Response) -> QueryResponse:
|
|
245
|
+
"""Turn a 200 response into a :class:`QueryResponse` (or NoDataError)."""
|
|
246
|
+
data = self._json_or_empty(response)
|
|
247
|
+
|
|
248
|
+
# The backend returns the answer under the key ``respons``; fall back to
|
|
249
|
+
# the correctly-spelled ``response`` so a backend fix can't break us.
|
|
250
|
+
answer = data.get("respons") or data.get("response") or ""
|
|
251
|
+
status = data.get("status", "success")
|
|
252
|
+
response_time = float(data.get("response_time_seconds", 0.0) or 0.0)
|
|
253
|
+
|
|
254
|
+
if status == "no_data":
|
|
255
|
+
# A successful call that found nothing relevant -- surface the
|
|
256
|
+
# backend's own message so callers know to upload documents.
|
|
257
|
+
raise self._fail(
|
|
258
|
+
NoDataError(
|
|
259
|
+
answer or "No relevant data found for this query.",
|
|
260
|
+
status_code=200,
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
return QueryResponse(
|
|
265
|
+
answer=answer,
|
|
266
|
+
status=status,
|
|
267
|
+
response_time=response_time,
|
|
268
|
+
conversation_id=self.conversation_id,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _raise_for_status(self, response: requests.Response) -> PluginAIError:
|
|
272
|
+
"""Map a non-200 response to the appropriate exception instance."""
|
|
273
|
+
code = response.status_code
|
|
274
|
+
detail = self._extract_detail(response)
|
|
275
|
+
|
|
276
|
+
if code == 401:
|
|
277
|
+
exc: PluginAIError = AuthenticationError(detail, status_code=code)
|
|
278
|
+
elif code == 403:
|
|
279
|
+
lowered = detail.lower()
|
|
280
|
+
if "quota" in lowered or "limit" in lowered:
|
|
281
|
+
exc = QuotaExceededError(detail, status_code=code)
|
|
282
|
+
else:
|
|
283
|
+
exc = AuthenticationError(detail, status_code=code)
|
|
284
|
+
elif code == 404:
|
|
285
|
+
exc = WorkspaceNotFoundError(detail, status_code=code)
|
|
286
|
+
elif code == 429:
|
|
287
|
+
exc = RateLimitError(detail, status_code=code)
|
|
288
|
+
elif 500 <= code < 600:
|
|
289
|
+
exc = ServerError(detail, status_code=code)
|
|
290
|
+
else:
|
|
291
|
+
exc = PluginAIError(detail, status_code=code)
|
|
292
|
+
|
|
293
|
+
return self._fail(exc)
|
|
294
|
+
|
|
295
|
+
# ------------------------------------------------------------------ #
|
|
296
|
+
# Small helpers
|
|
297
|
+
# ------------------------------------------------------------------ #
|
|
298
|
+
@staticmethod
|
|
299
|
+
def _json_or_empty(response: requests.Response) -> Dict[str, Any]:
|
|
300
|
+
"""Return parsed JSON, or an empty dict if the body isn't JSON."""
|
|
301
|
+
try:
|
|
302
|
+
data = response.json()
|
|
303
|
+
except ValueError:
|
|
304
|
+
return {}
|
|
305
|
+
return data if isinstance(data, dict) else {}
|
|
306
|
+
|
|
307
|
+
def _extract_detail(self, response: requests.Response) -> str:
|
|
308
|
+
"""Pull the ``detail`` error message from a response body."""
|
|
309
|
+
data = self._json_or_empty(response)
|
|
310
|
+
detail = data.get("detail")
|
|
311
|
+
if isinstance(detail, str) and detail:
|
|
312
|
+
return detail
|
|
313
|
+
return f"Request failed with HTTP {response.status_code}."
|
|
314
|
+
|
|
315
|
+
def _fail(self, exc: PluginAIError) -> PluginAIError:
|
|
316
|
+
"""Invoke the ``on_error`` callback (if any) and return the exception.
|
|
317
|
+
|
|
318
|
+
Returning the exception lets callers write ``raise self._fail(exc)`` so
|
|
319
|
+
the ``raise`` stays visible at the call site while the callback still
|
|
320
|
+
fires exactly once before propagation.
|
|
321
|
+
"""
|
|
322
|
+
if self.on_error is not None:
|
|
323
|
+
try:
|
|
324
|
+
self.on_error(exc)
|
|
325
|
+
except Exception:
|
|
326
|
+
# A misbehaving callback must never mask the real error.
|
|
327
|
+
pass
|
|
328
|
+
return exc
|
|
329
|
+
|
|
330
|
+
# ------------------------------------------------------------------ #
|
|
331
|
+
# Dunder / lifecycle
|
|
332
|
+
# ------------------------------------------------------------------ #
|
|
333
|
+
def __enter__(self) -> "PluginAI":
|
|
334
|
+
return self
|
|
335
|
+
|
|
336
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
337
|
+
self.close()
|
|
338
|
+
|
|
339
|
+
def __repr__(self) -> str:
|
|
340
|
+
# Never expose the API key -- show only non-sensitive identity.
|
|
341
|
+
return (
|
|
342
|
+
f"PluginAI(workspace={self.workspace!r}, "
|
|
343
|
+
f"conversation_id={self.conversation_id!r})"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
__all__ = ["PluginAI"]
|
pluginai/exceptions.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Exception hierarchy for the PluginAI SDK.
|
|
2
|
+
|
|
3
|
+
Every error raised by the SDK inherits from :class:`PluginAIError`, so callers
|
|
4
|
+
can catch that single base class to handle *any* SDK failure, or catch a more
|
|
5
|
+
specific subclass when they want to react differently (e.g. prompt the user to
|
|
6
|
+
upload documents on :class:`NoDataError`, or upgrade their plan on
|
|
7
|
+
:class:`QuotaExceededError`).
|
|
8
|
+
|
|
9
|
+
All exceptions carry two attributes:
|
|
10
|
+
|
|
11
|
+
* ``message`` -- a human-readable description of what went wrong.
|
|
12
|
+
* ``status_code`` -- the HTTP status code that triggered the error, or ``None``
|
|
13
|
+
when the failure happened before/without an HTTP response (e.g. a connection
|
|
14
|
+
or timeout error).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PluginAIError(Exception):
|
|
23
|
+
"""Base class for every error raised by the PluginAI SDK.
|
|
24
|
+
|
|
25
|
+
Catch this to handle any SDK failure in a single ``except`` block.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, status_code: Optional[int] = None) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.message: str = message
|
|
31
|
+
self.status_code: Optional[int] = status_code
|
|
32
|
+
|
|
33
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
34
|
+
if self.status_code is not None:
|
|
35
|
+
return f"[{self.status_code}] {self.message}"
|
|
36
|
+
return self.message
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AuthenticationError(PluginAIError):
|
|
40
|
+
"""Raised on HTTP 401/403 when the API key is wrong, missing, or inactive."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class QuotaExceededError(PluginAIError):
|
|
44
|
+
"""Raised on HTTP 403 when the response mentions a quota or usage limit."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class WorkspaceNotFoundError(PluginAIError):
|
|
48
|
+
"""Raised on HTTP 404 when the requested workspace does not exist."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class NoDataError(PluginAIError):
|
|
52
|
+
"""Raised when the backend returns ``status == "no_data"``.
|
|
53
|
+
|
|
54
|
+
This means the query succeeded but no relevant documents were found -- most
|
|
55
|
+
often because none have been uploaded/indexed in the workspace yet.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConnectionError(PluginAIError):
|
|
60
|
+
"""Raised when the backend server cannot be reached at all.
|
|
61
|
+
|
|
62
|
+
.. note::
|
|
63
|
+
This shadows the built-in :class:`ConnectionError` *within this package*
|
|
64
|
+
on purpose, so SDK users get a consistent ``pluginai``-namespaced error
|
|
65
|
+
type. Import the built-in explicitly if you need it alongside this one.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TimeoutError(PluginAIError):
|
|
70
|
+
"""Raised when a request takes longer than the configured ``timeout``.
|
|
71
|
+
|
|
72
|
+
.. note::
|
|
73
|
+
This shadows the built-in :class:`TimeoutError` *within this package* on
|
|
74
|
+
purpose, mirroring :class:`ConnectionError` above.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class RateLimitError(PluginAIError):
|
|
79
|
+
"""Raised on HTTP 429 when too many requests are sent in a short window."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ServerError(PluginAIError):
|
|
83
|
+
"""Raised on HTTP 5xx when the backend encounters an internal error."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
__all__ = [
|
|
87
|
+
"PluginAIError",
|
|
88
|
+
"AuthenticationError",
|
|
89
|
+
"QuotaExceededError",
|
|
90
|
+
"WorkspaceNotFoundError",
|
|
91
|
+
"NoDataError",
|
|
92
|
+
"ConnectionError",
|
|
93
|
+
"TimeoutError",
|
|
94
|
+
"RateLimitError",
|
|
95
|
+
"ServerError",
|
|
96
|
+
]
|
pluginai/models.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Data models returned and used by the PluginAI SDK.
|
|
2
|
+
|
|
3
|
+
These are plain :mod:`dataclasses` -- no third-party dependency -- so they are
|
|
4
|
+
cheap to construct, easy to inspect, and printable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
# Backend status strings.
|
|
14
|
+
STATUS_SUCCESS = "success"
|
|
15
|
+
STATUS_NO_DATA = "no_data"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class QueryResponse:
|
|
20
|
+
"""The result of a :meth:`pluginai.PluginAI.query` call.
|
|
21
|
+
|
|
22
|
+
Attributes:
|
|
23
|
+
answer: The AI-generated answer text.
|
|
24
|
+
status: Backend status -- ``"success"`` or ``"no_data"``.
|
|
25
|
+
response_time: Seconds the backend spent producing the answer.
|
|
26
|
+
conversation_id: The conversation id the query was grouped under.
|
|
27
|
+
cached: Reserved for a future caching layer; always ``False`` for now.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
answer: str
|
|
31
|
+
status: str
|
|
32
|
+
response_time: float
|
|
33
|
+
conversation_id: str
|
|
34
|
+
cached: bool = False
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def is_success(self) -> bool:
|
|
38
|
+
"""``True`` when the backend reported a successful answer."""
|
|
39
|
+
return self.status == STATUS_SUCCESS
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def has_data(self) -> bool:
|
|
43
|
+
"""``True`` unless the backend reported ``"no_data"``."""
|
|
44
|
+
return self.status != STATUS_NO_DATA
|
|
45
|
+
|
|
46
|
+
def __str__(self) -> str:
|
|
47
|
+
# Printing a response should just show the answer, so callers can do
|
|
48
|
+
# ``print(client.query("..."))`` and get the text they expect.
|
|
49
|
+
return self.answer
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ConversationMessage:
|
|
54
|
+
"""A single message within a :class:`Conversation`.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
role: Who produced the message -- ``"user"`` or ``"assistant"``.
|
|
58
|
+
content: The message text.
|
|
59
|
+
timestamp: When the message was created (defaults to now).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
role: str
|
|
63
|
+
content: str
|
|
64
|
+
timestamp: datetime = field(default_factory=datetime.now)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class Conversation:
|
|
69
|
+
"""A client-side record of a multi-turn conversation.
|
|
70
|
+
|
|
71
|
+
This is a convenience container for tracking history in your own app -- the
|
|
72
|
+
backend keys conversations off ``conversation_id``; this class just mirrors
|
|
73
|
+
the exchange locally.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
conversation_id: The id shared by every message in this conversation.
|
|
77
|
+
messages: The ordered list of messages.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
conversation_id: str
|
|
81
|
+
messages: List[ConversationMessage] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
def add_message(self, role: str, content: str) -> ConversationMessage:
|
|
84
|
+
"""Append a message and return the created :class:`ConversationMessage`.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
role: ``"user"`` or ``"assistant"``.
|
|
88
|
+
content: The message text.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The message that was appended.
|
|
92
|
+
"""
|
|
93
|
+
message = ConversationMessage(role=role, content=content)
|
|
94
|
+
self.messages.append(message)
|
|
95
|
+
return message
|
|
96
|
+
|
|
97
|
+
def get_history(self, last_n: int = 10) -> List[ConversationMessage]:
|
|
98
|
+
"""Return the most recent ``last_n`` messages.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
last_n: How many trailing messages to return (default 10).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Up to ``last_n`` messages, oldest first.
|
|
105
|
+
"""
|
|
106
|
+
if last_n <= 0:
|
|
107
|
+
return []
|
|
108
|
+
return self.messages[-last_n:]
|
|
109
|
+
|
|
110
|
+
def clear(self) -> None:
|
|
111
|
+
"""Remove every message from the conversation."""
|
|
112
|
+
self.messages.clear()
|
|
113
|
+
|
|
114
|
+
def __len__(self) -> int:
|
|
115
|
+
return len(self.messages)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = ["QueryResponse", "ConversationMessage", "Conversation"]
|
pluginai/utils.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Internal helper functions for the PluginAI SDK.
|
|
2
|
+
|
|
3
|
+
These are implementation details -- they are intentionally *not* re-exported
|
|
4
|
+
from :mod:`pluginai`. They handle conversation-id generation and input
|
|
5
|
+
validation so the client and models stay focused on their own concerns.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
|
|
13
|
+
# A workspace name may only contain letters, digits, hyphens and underscores.
|
|
14
|
+
_WORKSPACE_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
15
|
+
|
|
16
|
+
# Maximum length (in characters) accepted for a single query.
|
|
17
|
+
MAX_QUERY_LENGTH = 2000
|
|
18
|
+
|
|
19
|
+
# Minimum length (in characters) accepted for an API key.
|
|
20
|
+
MIN_API_KEY_LENGTH = 8
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_conversation_id() -> str:
|
|
24
|
+
"""Return a fresh, unique conversation id.
|
|
25
|
+
|
|
26
|
+
The id looks like ``conv_1a2b3c4d5e6f`` -- the ``conv_`` prefix makes it
|
|
27
|
+
easy to recognise in logs, followed by the first 12 hex chars of a UUID4.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
A new conversation id string.
|
|
31
|
+
"""
|
|
32
|
+
return "conv_" + uuid.uuid4().hex[:12]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_api_key(api_key: str) -> None:
|
|
36
|
+
"""Validate an API key, raising :class:`ValueError` when it is unusable.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
api_key: The workspace API key to check.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ValueError: If ``api_key`` is empty, ``None`` or shorter than
|
|
43
|
+
:data:`MIN_API_KEY_LENGTH` characters.
|
|
44
|
+
"""
|
|
45
|
+
if not api_key or not isinstance(api_key, str):
|
|
46
|
+
raise ValueError("api_key is required and must be a non-empty string.")
|
|
47
|
+
if len(api_key) < MIN_API_KEY_LENGTH:
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"api_key looks invalid: it must be at least {MIN_API_KEY_LENGTH} "
|
|
50
|
+
"characters long."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_workspace(workspace_name: str) -> None:
|
|
55
|
+
"""Validate a workspace name, raising :class:`ValueError` when invalid.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
workspace_name: The workspace name to check.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If ``workspace_name`` is empty, ``None`` or contains any
|
|
62
|
+
character other than letters, digits, hyphens or underscores.
|
|
63
|
+
"""
|
|
64
|
+
if not workspace_name or not isinstance(workspace_name, str):
|
|
65
|
+
raise ValueError("workspace is required and must be a non-empty string.")
|
|
66
|
+
if not _WORKSPACE_RE.match(workspace_name):
|
|
67
|
+
raise ValueError(
|
|
68
|
+
"workspace may only contain letters, numbers, hyphens and "
|
|
69
|
+
f"underscores (got {workspace_name!r})."
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def validate_query(query: str) -> None:
|
|
74
|
+
"""Validate a query string, raising :class:`ValueError` when invalid.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
query: The natural-language question to check.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: If ``query`` is empty, ``None``, whitespace-only, or longer
|
|
81
|
+
than :data:`MAX_QUERY_LENGTH` characters.
|
|
82
|
+
"""
|
|
83
|
+
if not query or not isinstance(query, str) or not query.strip():
|
|
84
|
+
raise ValueError("query must be a non-empty, non-whitespace string.")
|
|
85
|
+
if len(query) > MAX_QUERY_LENGTH:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"query is too long: {len(query)} characters "
|
|
88
|
+
f"(maximum is {MAX_QUERY_LENGTH})."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def mask_api_key(api_key: str) -> str:
|
|
93
|
+
"""Return a masked version of an API key that is safe to log or display.
|
|
94
|
+
|
|
95
|
+
Keeps the last four characters visible and replaces everything else with
|
|
96
|
+
asterisks, e.g. ``"sk-abcdef1234"`` -> ``"********1234"``. Short or empty
|
|
97
|
+
keys are fully masked.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
api_key: The API key to mask.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The masked key.
|
|
104
|
+
"""
|
|
105
|
+
if not api_key or not isinstance(api_key, str) or len(api_key) <= 4:
|
|
106
|
+
return "****"
|
|
107
|
+
return "*" * (len(api_key) - 4) + api_key[-4:]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"generate_conversation_id",
|
|
112
|
+
"validate_api_key",
|
|
113
|
+
"validate_workspace",
|
|
114
|
+
"validate_query",
|
|
115
|
+
"mask_api_key",
|
|
116
|
+
]
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pluginai
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Official Python SDK for PluginAI RAG Platform
|
|
5
|
+
Home-page: https://pluginai.space
|
|
6
|
+
Author: Plugin AI
|
|
7
|
+
Author-email: Plugin AI <support@pluginai.space>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://pluginai.space
|
|
10
|
+
Project-URL: Documentation, https://pluginai.space/docs
|
|
11
|
+
Project-URL: Source, https://pluginai.space
|
|
12
|
+
Keywords: pluginai,rag,chatbot,ai,llm,knowledge-base
|
|
13
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Requires-Python: >=3.8
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: requests>=2.28.0
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
30
|
+
Requires-Dist: responses>=0.23; extra == "dev"
|
|
31
|
+
Requires-Dist: build>=0.10; extra == "dev"
|
|
32
|
+
Requires-Dist: twine>=4.0; extra == "dev"
|
|
33
|
+
Dynamic: author
|
|
34
|
+
Dynamic: home-page
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
Dynamic: requires-python
|
|
37
|
+
|
|
38
|
+
# PluginAI Python SDK
|
|
39
|
+
|
|
40
|
+
Official Python SDK for the **PluginAI** RAG platform. Turn your documents into
|
|
41
|
+
an intelligent, API-accessible assistant, and query it from any Python app in
|
|
42
|
+
under five minutes.
|
|
43
|
+
|
|
44
|
+
- 🐍 Pure Python, one dependency (`requests`)
|
|
45
|
+
- 🔁 Synchronous — works in scripts, notebooks, Flask, Django, FastAPI, anywhere
|
|
46
|
+
- 💬 Built-in multi-turn conversation tracking
|
|
47
|
+
- 🧯 Rich, catchable exception hierarchy
|
|
48
|
+
- ✅ Python 3.8 – 3.12
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install pluginai
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Quickstart
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from pluginai import PluginAI
|
|
64
|
+
|
|
65
|
+
client = PluginAI(
|
|
66
|
+
api_key="your-api-key",
|
|
67
|
+
workspace="your-workspace-name",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
response = client.query("What is the refund policy?")
|
|
71
|
+
print(response.answer)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`print(response)` also works — a `QueryResponse` stringifies to its answer text.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Multi-turn conversations
|
|
79
|
+
|
|
80
|
+
Every query from the same client shares one `conversation_id`, so the backend
|
|
81
|
+
keeps context across turns. Start over with `reset_conversation()`.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
client.query("What products do you offer?")
|
|
85
|
+
client.query("Which is cheapest?") # remembers the previous turn
|
|
86
|
+
|
|
87
|
+
new_id = client.reset_conversation() # fresh conversation from here on
|
|
88
|
+
client.query("Let's start over.")
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Use it as a context manager to close the HTTP session automatically:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
with PluginAI(api_key="...", workspace="...") as client:
|
|
95
|
+
print(client.query("Hello").answer)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Agentic queries
|
|
101
|
+
|
|
102
|
+
For complex, multi-step questions, use `agent_query()` — same signature and
|
|
103
|
+
return type as `query()`, but routed through the Agentic RAG pipeline:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
response = client.agent_query(
|
|
107
|
+
"Compare the 2023 and 2024 refund policies and summarize what changed."
|
|
108
|
+
)
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Configuration
|
|
114
|
+
|
|
115
|
+
All constructor parameters:
|
|
116
|
+
|
|
117
|
+
| Parameter | Type | Default | Description |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| `api_key` | `str` | **required** | Your workspace API key. |
|
|
120
|
+
| `workspace` | `str` | **required** | The workspace name to query. |
|
|
121
|
+
| `base_url` | `str` | hosted PluginAI API | PluginAI backend URL. Only override if you self-host (trailing slash stripped). |
|
|
122
|
+
| `conversation_id` | `str` | auto-generated | Pass to continue an existing conversation. |
|
|
123
|
+
| `timeout` | `int` | `30` | Per-request timeout, in seconds. |
|
|
124
|
+
| `max_retries` | `int` | `2` | Retries on transient server errors (502/503/504) only. |
|
|
125
|
+
| `on_error` | `callable` | `None` | Called with the exception right before any error is raised. |
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## The response object
|
|
130
|
+
|
|
131
|
+
`client.query(...)` returns a `QueryResponse`:
|
|
132
|
+
|
|
133
|
+
| Attribute / property | Type | Description |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `answer` | `str` | The AI-generated answer text. |
|
|
136
|
+
| `status` | `str` | `"success"` or `"no_data"`. |
|
|
137
|
+
| `response_time` | `float` | Seconds the backend took. |
|
|
138
|
+
| `conversation_id` | `str` | The conversation the query belonged to. |
|
|
139
|
+
| `cached` | `bool` | Reserved for future use (always `False`). |
|
|
140
|
+
| `is_success` | `bool` | `True` when `status == "success"`. |
|
|
141
|
+
| `has_data` | `bool` | `False` when `status == "no_data"`. |
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Error handling
|
|
146
|
+
|
|
147
|
+
Every SDK error inherits from `PluginAIError`, so you can catch that one base
|
|
148
|
+
class or handle specific cases:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from pluginai import (
|
|
152
|
+
PluginAI,
|
|
153
|
+
AuthenticationError,
|
|
154
|
+
NoDataError,
|
|
155
|
+
QuotaExceededError,
|
|
156
|
+
TimeoutError,
|
|
157
|
+
PluginAIError,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
response = client.query("What is the refund policy?")
|
|
162
|
+
print(response.answer)
|
|
163
|
+
except AuthenticationError:
|
|
164
|
+
print("Check your API key.")
|
|
165
|
+
except NoDataError:
|
|
166
|
+
print("Upload documents to this workspace first.")
|
|
167
|
+
except QuotaExceededError:
|
|
168
|
+
print("Upgrade your plan.")
|
|
169
|
+
except TimeoutError:
|
|
170
|
+
print("Increase the `timeout` parameter.")
|
|
171
|
+
except PluginAIError as exc:
|
|
172
|
+
print(f"Something went wrong: {exc}")
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
| Exception | Raised when |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `PluginAIError` | Base class for all SDK errors. |
|
|
178
|
+
| `AuthenticationError` | HTTP 401/403 — wrong or inactive API key. |
|
|
179
|
+
| `QuotaExceededError` | HTTP 403 mentioning a quota / usage limit. |
|
|
180
|
+
| `WorkspaceNotFoundError` | HTTP 404 — workspace doesn't exist. |
|
|
181
|
+
| `NoDataError` | Backend returned `status == "no_data"`. |
|
|
182
|
+
| `ConnectionError` | Backend unreachable. |
|
|
183
|
+
| `TimeoutError` | Request exceeded `timeout`. |
|
|
184
|
+
| `RateLimitError` | HTTP 429 — too many requests. |
|
|
185
|
+
| `ServerError` | HTTP 5xx — backend error. |
|
|
186
|
+
|
|
187
|
+
Every exception carries `.message` (str) and `.status_code` (int or `None`).
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Framework integration
|
|
192
|
+
|
|
193
|
+
See the [`examples/`](examples/) directory for runnable samples:
|
|
194
|
+
|
|
195
|
+
- [`basic_usage.py`](examples/basic_usage.py)
|
|
196
|
+
- [`multi_turn_conversation.py`](examples/multi_turn_conversation.py)
|
|
197
|
+
- [`error_handling.py`](examples/error_handling.py)
|
|
198
|
+
- [`flask_integration.py`](examples/flask_integration.py)
|
|
199
|
+
- [`django_integration.py`](examples/django_integration.py)
|
|
200
|
+
|
|
201
|
+
**Tip:** create the client **once** at app startup and reuse it — that keeps the
|
|
202
|
+
connection pool warm instead of paying setup cost on every request.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
git clone <repo-url>
|
|
210
|
+
cd pluginai-python
|
|
211
|
+
pip install -e ".[dev]" # or: pip install -r requirements-dev.txt
|
|
212
|
+
pytest tests/
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Publishing to PyPI
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
pip install build twine
|
|
221
|
+
python -m build # builds sdist + wheel into dist/
|
|
222
|
+
pytest tests/ # make sure everything is green
|
|
223
|
+
twine check dist/*
|
|
224
|
+
twine upload dist/* # prompts for your PyPI token
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
---
|
|
228
|
+
|
|
229
|
+
## License
|
|
230
|
+
|
|
231
|
+
[MIT](LICENSE) © Plugin AI
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pluginai/__init__.py,sha256=o3zvaTvquLuyanp3Ah6TjIJcyEvXJ0FqG4CTC-m0ffw,1107
|
|
2
|
+
pluginai/client.py,sha256=kWpK4Fr4Bh_zOZbpa0zth0v_68qr5QDd04oyxUn_BP0,12899
|
|
3
|
+
pluginai/exceptions.py,sha256=yhAt0skCtlKCLaCXeQJlx1bf0j_W_xNcBhzYsPRPpRY,3026
|
|
4
|
+
pluginai/models.py,sha256=zMDu3VkT37SQNS3HH--jf8Rsto2KTRAKQkCtTIHgbvs,3506
|
|
5
|
+
pluginai/utils.py,sha256=53pxq8dkuOQ-Jh0TvxuAe_csfpUSfx_a27IZ3XvFVSk,3695
|
|
6
|
+
pluginai-1.0.0.dist-info/licenses/LICENSE,sha256=eAGkb790yiMccWHJieRf1lbpNuIzOMd1mb6kAKcy3B0,1066
|
|
7
|
+
pluginai-1.0.0.dist-info/METADATA,sha256=lGycRstlxjcBL2GzxR6az7q6cgPhbVAlQ9b_M_OEEq4,7009
|
|
8
|
+
pluginai-1.0.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
9
|
+
pluginai-1.0.0.dist-info/top_level.txt,sha256=sS-FHsI4QZzDnFc6VlFUp6_IkXqJxQicRqqo9tjDRJE,9
|
|
10
|
+
pluginai-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Plugin AI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pluginai
|