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