needlesearchai 2.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.4
2
+ Name: needlesearchai
3
+ Version: 2.1.0
4
+ Summary: Python SDK for the NeedleSearch AI legal document search API
5
+ License: MIT
6
+ Project-URL: Homepage, https://needlesearch.ai
7
+ Project-URL: Documentation, https://needlesearch.ai/docs
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/x-rst
10
+ Requires-Dist: httpx>=0.27
11
+ Provides-Extra: http
12
+ Requires-Dist: httpx>=0.27; extra == "http"
13
+
14
+ NeedleSearch Python SDK
15
+ =======================
16
+
17
+ Client for the NeedleSearch API — semantic search, grounded chat and deep
18
+ research over your own document corpus.
19
+
20
+ Install
21
+ -------
22
+
23
+ Not on PyPI yet. Install from the repository:
24
+
25
+ .. code-block:: bash
26
+
27
+ pip install "git+https://github.com/Legal-Analytics-Inc/AI-LegalSearch#subdirectory=sdk/python"
28
+
29
+ Once released the command becomes ``pip install needlesearchai``. Note the
30
+ name: plain ``needlesearch`` on PyPI is an abandoned registration with no files
31
+ behind it — it is not us, and installing it fails.
32
+
33
+ Quick start
34
+ -----------
35
+
36
+ .. code-block:: python
37
+
38
+ from needlesearchai import NeedleSearch
39
+
40
+ ns = NeedleSearch(api_key="nsk_...", base_url="https://your-instance.com/v1")
41
+
42
+ # Is it us or is it you? Needs no key.
43
+ print(ns.status().status)
44
+
45
+ # Semantic search, optionally with an AI overview over the matches
46
+ results = ns.search("termination clauses")
47
+ answer = ns.search("How long is the notice period?", include_overview=True)
48
+
49
+ # Browse. Prefer iterate() over offset paging: it follows the cursor, which
50
+ # is stable while other people are uploading.
51
+ for item in ns.items.iterate():
52
+ print(item.name, item.status)
53
+
54
+ # Deep research. The async form survives proxies that cut long connections.
55
+ job = ns.jobs.create("What are the risks in this contract?", item_ids=[doc_id])
56
+ print(ns.jobs.wait(job.id).answer)
57
+
58
+ Document status
59
+ ---------------
60
+
61
+ ``item.status`` is the published lifecycle — ``queued``, ``processing``,
62
+ ``ready``, ``failed``, ``cancelled`` — and is the one to branch on.
63
+ ``item.status_detail`` carries the pipeline's internal stage; it is useful in a
64
+ log or a support ticket and carries no compatibility promise.
65
+
66
+ .. code-block:: python
67
+
68
+ item = ns.items.get(doc_id)
69
+ if item.is_ready:
70
+ text = ns.items.content(doc_id)["markdown"]
71
+ elif item.is_terminal:
72
+ print("gave up:", item.error)
73
+
74
+ Errors
75
+ ------
76
+
77
+ Every failure raises a subclass of ``NeedleSearchError`` carrying the API's
78
+ machine-readable code:
79
+
80
+ .. code-block:: python
81
+
82
+ from needlesearchai import NeedleSearchError, RateLimitError
83
+
84
+ try:
85
+ ns.search("...")
86
+ except RateLimitError as e:
87
+ time.sleep(e.retry_after)
88
+ except NeedleSearchError as e:
89
+ print(e.error_code, e.status_code) # e.g. "insufficient_scope", 403
90
+
91
+ Webhooks
92
+ --------
93
+
94
+ Verify every delivery before trusting it. ``verify_signature`` does the two
95
+ things a hand-rolled check usually gets wrong — a constant-time comparison, and
96
+ rejecting a replayed capture by its timestamp:
97
+
98
+ .. code-block:: python
99
+
100
+ from needlesearchai import verify_signature
101
+
102
+ # `raw_body` must be the bytes as received; re-serializing the parsed JSON
103
+ # changes them and the signature will not match.
104
+ if not verify_signature(secret, raw_body, request.headers["X-NeedleSearch-Signature"]):
105
+ return 400
106
+
107
+ Retries
108
+ -------
109
+
110
+ Send an ``Idempotency-Key`` on anything you might retry — research runs and
111
+ webhook registrations both cost something you do not want to pay twice:
112
+
113
+ .. code-block:: python
114
+
115
+ job = ns.jobs.create("...", idempotency_key="batch-2026-02-01-run-7")
116
+ hook = ns.webhooks.create("https://example.com/hook", idempotency_key="hook-v1")
117
+
118
+ License
119
+ -------
120
+
121
+ MIT
@@ -0,0 +1,108 @@
1
+ NeedleSearch Python SDK
2
+ =======================
3
+
4
+ Client for the NeedleSearch API — semantic search, grounded chat and deep
5
+ research over your own document corpus.
6
+
7
+ Install
8
+ -------
9
+
10
+ Not on PyPI yet. Install from the repository:
11
+
12
+ .. code-block:: bash
13
+
14
+ pip install "git+https://github.com/Legal-Analytics-Inc/AI-LegalSearch#subdirectory=sdk/python"
15
+
16
+ Once released the command becomes ``pip install needlesearchai``. Note the
17
+ name: plain ``needlesearch`` on PyPI is an abandoned registration with no files
18
+ behind it — it is not us, and installing it fails.
19
+
20
+ Quick start
21
+ -----------
22
+
23
+ .. code-block:: python
24
+
25
+ from needlesearchai import NeedleSearch
26
+
27
+ ns = NeedleSearch(api_key="nsk_...", base_url="https://your-instance.com/v1")
28
+
29
+ # Is it us or is it you? Needs no key.
30
+ print(ns.status().status)
31
+
32
+ # Semantic search, optionally with an AI overview over the matches
33
+ results = ns.search("termination clauses")
34
+ answer = ns.search("How long is the notice period?", include_overview=True)
35
+
36
+ # Browse. Prefer iterate() over offset paging: it follows the cursor, which
37
+ # is stable while other people are uploading.
38
+ for item in ns.items.iterate():
39
+ print(item.name, item.status)
40
+
41
+ # Deep research. The async form survives proxies that cut long connections.
42
+ job = ns.jobs.create("What are the risks in this contract?", item_ids=[doc_id])
43
+ print(ns.jobs.wait(job.id).answer)
44
+
45
+ Document status
46
+ ---------------
47
+
48
+ ``item.status`` is the published lifecycle — ``queued``, ``processing``,
49
+ ``ready``, ``failed``, ``cancelled`` — and is the one to branch on.
50
+ ``item.status_detail`` carries the pipeline's internal stage; it is useful in a
51
+ log or a support ticket and carries no compatibility promise.
52
+
53
+ .. code-block:: python
54
+
55
+ item = ns.items.get(doc_id)
56
+ if item.is_ready:
57
+ text = ns.items.content(doc_id)["markdown"]
58
+ elif item.is_terminal:
59
+ print("gave up:", item.error)
60
+
61
+ Errors
62
+ ------
63
+
64
+ Every failure raises a subclass of ``NeedleSearchError`` carrying the API's
65
+ machine-readable code:
66
+
67
+ .. code-block:: python
68
+
69
+ from needlesearchai import NeedleSearchError, RateLimitError
70
+
71
+ try:
72
+ ns.search("...")
73
+ except RateLimitError as e:
74
+ time.sleep(e.retry_after)
75
+ except NeedleSearchError as e:
76
+ print(e.error_code, e.status_code) # e.g. "insufficient_scope", 403
77
+
78
+ Webhooks
79
+ --------
80
+
81
+ Verify every delivery before trusting it. ``verify_signature`` does the two
82
+ things a hand-rolled check usually gets wrong — a constant-time comparison, and
83
+ rejecting a replayed capture by its timestamp:
84
+
85
+ .. code-block:: python
86
+
87
+ from needlesearchai import verify_signature
88
+
89
+ # `raw_body` must be the bytes as received; re-serializing the parsed JSON
90
+ # changes them and the signature will not match.
91
+ if not verify_signature(secret, raw_body, request.headers["X-NeedleSearch-Signature"]):
92
+ return 400
93
+
94
+ Retries
95
+ -------
96
+
97
+ Send an ``Idempotency-Key`` on anything you might retry — research runs and
98
+ webhook registrations both cost something you do not want to pay twice:
99
+
100
+ .. code-block:: python
101
+
102
+ job = ns.jobs.create("...", idempotency_key="batch-2026-02-01-run-7")
103
+ hook = ns.webhooks.create("https://example.com/hook", idempotency_key="hook-v1")
104
+
105
+ License
106
+ -------
107
+
108
+ MIT
@@ -0,0 +1,78 @@
1
+ """NeedleSearch Python SDK.
2
+
3
+ Quick start:
4
+ from needlesearchai import NeedleSearch
5
+
6
+ ns = NeedleSearch(api_key="nsk_...")
7
+ results = ns.search("termination clauses") # vector search
8
+ overview = ns.search("notice period?", include_overview=True) # search + AI answer
9
+ for chunk in ns.chat.stream("Explain promissory estoppel"): # plain chat (no docs)
10
+ print(chunk.text, end="", flush=True)
11
+
12
+ for item in ns.items.iterate(): # browse, cursor-paged
13
+ print(item.name, item.status)
14
+
15
+ job = ns.jobs.create("Summarise the risks", item_ids=[doc_id]) # async research
16
+ print(ns.jobs.wait(job.id).answer)
17
+ """
18
+ from needlesearchai.client import NeedleSearch
19
+ from needlesearchai.exceptions import (
20
+ AuthError,
21
+ DocumentNotReadyError,
22
+ InsufficientScopeError,
23
+ NeedleSearchError,
24
+ NotFoundError,
25
+ QuotaError,
26
+ RateLimitError,
27
+ ServerError,
28
+ )
29
+ from needlesearchai.types import (
30
+ ChatChunk,
31
+ FileRecord,
32
+ Item,
33
+ ItemPage,
34
+ MeInfo,
35
+ QuotaInfo,
36
+ ResearchJob,
37
+ ResearchResult,
38
+ SearchResponse,
39
+ SearchResult,
40
+ ServiceStatus,
41
+ UploadJob,
42
+ UploadJobPage,
43
+ UploadResult,
44
+ Webhook,
45
+ WebhookDelivery,
46
+ )
47
+ from needlesearchai.webhooks import verify_signature
48
+
49
+ __all__ = [
50
+ "NeedleSearch",
51
+ "NeedleSearchError",
52
+ "AuthError",
53
+ "InsufficientScopeError",
54
+ "RateLimitError",
55
+ "QuotaError",
56
+ "NotFoundError",
57
+ "ServerError",
58
+ "DocumentNotReadyError",
59
+ "SearchResult",
60
+ "SearchResponse",
61
+ "ChatChunk",
62
+ "FileRecord",
63
+ "UploadResult",
64
+ "QuotaInfo",
65
+ "MeInfo",
66
+ "ResearchResult",
67
+ "Item",
68
+ "ItemPage",
69
+ "ResearchJob",
70
+ "ServiceStatus",
71
+ "UploadJob",
72
+ "UploadJobPage",
73
+ "Webhook",
74
+ "WebhookDelivery",
75
+ "verify_signature",
76
+ ]
77
+
78
+ __version__ = "2.1.0"
@@ -0,0 +1,138 @@
1
+ """HTTP client wrapper with auth, retry, and error handling."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from typing import Any, Generator
6
+
7
+ try:
8
+ import httpx
9
+ except ImportError as e:
10
+ raise ImportError("needlesearchai requires httpx. Install with: pip install needlesearchai[http]") from e
11
+
12
+ from needlesearchai.exceptions import (
13
+ AuthError,
14
+ DocumentNotReadyError,
15
+ InsufficientScopeError,
16
+ NeedleSearchError,
17
+ NotFoundError,
18
+ QuotaError,
19
+ RateLimitError,
20
+ ServerError,
21
+ )
22
+
23
+ _DEFAULT_TIMEOUT = 30.0
24
+ _STREAM_TIMEOUT = 300.0
25
+ _MAX_RETRIES = 3
26
+
27
+
28
+ def _raise_for_status(response: httpx.Response) -> None:
29
+ if response.is_success:
30
+ return
31
+
32
+ try:
33
+ body = response.json()
34
+ except Exception:
35
+ body = {}
36
+
37
+ error_code = body.get("error", "")
38
+ try:
39
+ raw_text = response.text[:300]
40
+ except Exception:
41
+ raw_text = ""
42
+ message = body.get("message", body.get("detail", raw_text))
43
+ status = response.status_code
44
+
45
+ if status == 401:
46
+ raise AuthError(message or "Invalid or missing API key", status_code=status, error_code=error_code)
47
+ if status == 403:
48
+ if error_code == "insufficient_scope":
49
+ raise InsufficientScopeError(message, status_code=status, error_code=error_code)
50
+ raise AuthError(message, status_code=status, error_code=error_code)
51
+ if status == 404:
52
+ raise NotFoundError(message, status_code=status, error_code=error_code)
53
+ if status == 429:
54
+ if error_code == "monthly_quota_exceeded":
55
+ raise QuotaError(message, used=body.get("used"), limit=body.get("limit"))
56
+ retry_after = int(response.headers.get("Retry-After", 60))
57
+ raise RateLimitError(message, retry_after=retry_after)
58
+ if status == 400:
59
+ if error_code == "document_not_ready":
60
+ raise DocumentNotReadyError(message, status_code=status, error_code=error_code)
61
+ raise NeedleSearchError(message, status_code=status, error_code=error_code)
62
+ if status >= 500:
63
+ raise ServerError(message or f"Server error {status}", status_code=status)
64
+
65
+ raise NeedleSearchError(message or f"HTTP {status}", status_code=status, error_code=error_code)
66
+
67
+
68
+ class HttpClient:
69
+ """Synchronous HTTP client with exponential backoff retry."""
70
+
71
+ def __init__(self, api_key: str, base_url: str, timeout: float = _DEFAULT_TIMEOUT):
72
+ self._client = httpx.Client(
73
+ base_url=base_url,
74
+ headers={"Authorization": f"Bearer {api_key}", "User-Agent": "needlesearchai-python/1.0"},
75
+ timeout=timeout,
76
+ )
77
+
78
+ def get(self, path: str, **params) -> Any:
79
+ return self._request("GET", path, params=params or None)
80
+
81
+ def post(self, path: str, json: Any = None, *, idempotency_key: str | None = None) -> Any:
82
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else None
83
+ return self._request("POST", path, json=json, headers=headers)
84
+
85
+ def patch(self, path: str, json: Any = None) -> Any:
86
+ return self._request("PATCH", path, json=json)
87
+
88
+ def delete(self, path: str) -> Any:
89
+ return self._request("DELETE", path)
90
+
91
+ def _request(self, method: str, path: str, **kwargs) -> Any:
92
+ for attempt in range(_MAX_RETRIES):
93
+ try:
94
+ # Drop unset optionals so httpx does not send empty headers.
95
+ call = {k: v for k, v in kwargs.items() if v is not None}
96
+ r = self._client.request(method, path, **call)
97
+ _raise_for_status(r)
98
+ return r.json() if r.content else None
99
+ except RateLimitError as e:
100
+ if attempt == _MAX_RETRIES - 1:
101
+ raise
102
+ wait = min(e.retry_after, 30)
103
+ time.sleep(wait)
104
+ except (httpx.TimeoutException, httpx.NetworkError) as e:
105
+ if attempt == _MAX_RETRIES - 1:
106
+ raise NeedleSearchError(f"Network error: {e}") from e
107
+ time.sleep(2 ** attempt)
108
+
109
+ def raw(self, method: str, path: str, **kwargs):
110
+ """A checked response with its body intact — for endpoints that return
111
+ bytes rather than JSON (file downloads)."""
112
+ r = self._client.request(method, path, **kwargs)
113
+ _raise_for_status(r)
114
+ return r
115
+
116
+ def stream(self, path: str, json: Any = None) -> Generator[str, None, None]:
117
+ """Yield raw SSE data lines from a streaming endpoint."""
118
+ with self._client.stream("POST", path, json=json, timeout=_STREAM_TIMEOUT) as r:
119
+ _raise_for_status(r)
120
+ parts: list[str] = []
121
+ for line in r.iter_lines():
122
+ if line.startswith("data:"):
123
+ raw = line[5:]
124
+ parts.append(raw[1:] if raw.startswith(" ") else raw)
125
+ elif line == "" and parts:
126
+ yield "\n".join(parts)
127
+ parts = []
128
+ if parts:
129
+ yield "\n".join(parts)
130
+
131
+ def close(self):
132
+ self._client.close()
133
+
134
+ def __enter__(self):
135
+ return self
136
+
137
+ def __exit__(self, *args):
138
+ self.close()
@@ -0,0 +1,69 @@
1
+ """NeedleSearch SDK — Chat API with streaming support."""
2
+ from __future__ import annotations
3
+ import json
4
+ from typing import Generator
5
+
6
+ from needlesearchai.types import ChatChunk
7
+
8
+
9
+ class ChatAPI:
10
+ """Wrapper for POST /v1/chat."""
11
+
12
+ def __init__(self, http):
13
+ self._http = http
14
+
15
+ def stream(
16
+ self,
17
+ message: str,
18
+ *,
19
+ provider: str | None = None,
20
+ ) -> Generator[ChatChunk, None, None]:
21
+ """Stream a plain chat response from the AI.
22
+
23
+ Conversational chat only — it does NOT read your documents. For
24
+ document-grounded answers use ``client.search(..., include_overview=True)``
25
+ or the research API.
26
+
27
+ Args:
28
+ message: User question or instruction
29
+ provider: LLM provider override ('deepseek', 'openai', 'anthropic')
30
+
31
+ Yields:
32
+ ChatChunk with .text containing the streaming text
33
+
34
+ Example:
35
+ for chunk in ns.chat.stream("Explain promissory estoppel"):
36
+ print(chunk.text, end="", flush=True)
37
+ """
38
+ body = {"message": message}
39
+ if provider:
40
+ body["provider"] = provider
41
+
42
+ for data_line in self._http.stream("/chat", json=body):
43
+ if not data_line:
44
+ continue
45
+ try:
46
+ payload = json.loads(data_line)
47
+ # Only yield actual text delta events
48
+ if isinstance(payload, str):
49
+ yield ChatChunk(text=payload)
50
+ elif isinstance(payload, dict) and "text" in payload:
51
+ yield ChatChunk(text=payload["text"])
52
+ elif isinstance(payload, dict) and payload.get("event") == "done":
53
+ break
54
+ except (json.JSONDecodeError, TypeError):
55
+ # Plain text delta
56
+ if data_line and not data_line.startswith("{"):
57
+ yield ChatChunk(text=data_line)
58
+
59
+ def complete(self, message: str, **kwargs) -> str:
60
+ """Non-streaming chat — collects all chunks and returns the full text.
61
+
62
+ Args:
63
+ message: User question or instruction
64
+ **kwargs: Same keyword arguments as stream()
65
+
66
+ Returns:
67
+ Complete AI response as a string
68
+ """
69
+ return "".join(chunk.text for chunk in self.stream(message, **kwargs))
@@ -0,0 +1,114 @@
1
+ """NeedleSearch Python SDK — main client."""
2
+ from __future__ import annotations
3
+
4
+ from needlesearchai._http import HttpClient
5
+ from needlesearchai.chat import ChatAPI
6
+ from needlesearchai.files import FilesAPI
7
+ from needlesearchai.folders import FoldersAPI
8
+ from needlesearchai.items import ItemsAPI
9
+ from needlesearchai.jobs import ResearchJobsAPI
10
+ from needlesearchai.research import ResearchAPI
11
+ from needlesearchai.search import SearchAPI
12
+ from needlesearchai.types import MeInfo, QuotaInfo, ServiceStatus
13
+ from needlesearchai.uploads import UploadsAPI
14
+ from needlesearchai.webhooks import WebhooksAPI
15
+
16
+ _DEFAULT_BASE_URL = "https://your-instance.com/v1"
17
+
18
+
19
+ class NeedleSearch:
20
+ """NeedleSearch API client.
21
+
22
+ Usage:
23
+ from needlesearchai import NeedleSearch
24
+
25
+ ns = NeedleSearch(api_key="nsk_...")
26
+
27
+ # Semantic search (optionally with an AI overview over your documents)
28
+ results = ns.search("What are the termination clauses?")
29
+ overview = ns.search("How long is the notice period?", include_overview=True)
30
+
31
+ # Streaming chat — plain conversation, does NOT read your documents
32
+ for chunk in ns.chat.stream("Explain promissory estoppel"):
33
+ print(chunk.text, end="", flush=True)
34
+
35
+ # Deep research over your documents (Starter+ plan)
36
+ answer = ns.research.complete("What are the risks in this contract?", document_ids=["..."])
37
+ """
38
+
39
+ def __init__(self, api_key: str, base_url: str = _DEFAULT_BASE_URL, timeout: float = 30.0):
40
+ """Create a NeedleSearch client.
41
+
42
+ Args:
43
+ api_key: Your API key (starts with 'nsk_')
44
+ base_url: API base URL (default: https://your-instance.com/v1)
45
+ timeout: Request timeout in seconds
46
+ """
47
+ if not api_key:
48
+ raise ValueError("api_key is required")
49
+ if not api_key.startswith("nsk_"):
50
+ raise ValueError("api_key must start with 'nsk_'")
51
+
52
+ self._http = HttpClient(api_key=api_key, base_url=base_url.rstrip("/"), timeout=timeout)
53
+
54
+ self.search = SearchAPI(self._http)
55
+ self.chat = ChatAPI(self._http)
56
+ self.research = ResearchAPI(self._http)
57
+ self.files = FilesAPI(self._http)
58
+ self.items = ItemsAPI(self._http)
59
+ self.folders = FoldersAPI(self._http)
60
+ self.webhooks = WebhooksAPI(self._http)
61
+ self.jobs = ResearchJobsAPI(self._http)
62
+ self.uploads = UploadsAPI(self._http)
63
+
64
+ def status(self) -> ServiceStatus:
65
+ """Whether the API can currently serve each class of operation.
66
+
67
+ The one call that needs no key — so it still answers when the key itself
68
+ is the suspect. `down` means retry with backoff rather than debugging
69
+ your integration.
70
+ """
71
+ data = self._http.get("/status")
72
+ return ServiceStatus(
73
+ status=data.get("status", "unknown"),
74
+ services=data.get("services", {}),
75
+ api_version=data.get("api_version", "v1"),
76
+ checked_at=data.get("checked_at"),
77
+ )
78
+
79
+ def me(self) -> MeInfo:
80
+ """Return identity and plan information for this API key."""
81
+ data = self._http.get("/me")
82
+ return MeInfo(
83
+ key_prefix=data.get("key_prefix", ""),
84
+ name=data.get("name", ""),
85
+ plan=data.get("plan", "free"),
86
+ scopes=data.get("scopes", []),
87
+ organization_id=data.get("organization_id", ""),
88
+ features=data.get("features", {}),
89
+ created_at=data.get("created_at"),
90
+ )
91
+
92
+ def quota(self) -> QuotaInfo:
93
+ """Return current quota and plan status."""
94
+ data = self._http.get("/quota")
95
+ monthly = data.get("monthly_requests", {})
96
+ return QuotaInfo(
97
+ plan=data.get("plan", "free"),
98
+ monthly_limit=monthly.get("limit"),
99
+ monthly_used=monthly.get("used", 0),
100
+ monthly_remaining=monthly.get("remaining"),
101
+ rate_limit_rpm=data.get("rate_limit_rpm", 0),
102
+ features=data.get("features", {}),
103
+ reset_at=data.get("reset_at"),
104
+ )
105
+
106
+ def close(self) -> None:
107
+ """Close the underlying HTTP client."""
108
+ self._http.close()
109
+
110
+ def __enter__(self):
111
+ return self
112
+
113
+ def __exit__(self, *args):
114
+ self.close()
@@ -0,0 +1,47 @@
1
+ """NeedleSearch SDK exceptions."""
2
+
3
+
4
+ class NeedleSearchError(Exception):
5
+ """Base exception for all NeedleSearch errors."""
6
+ def __init__(self, message: str, status_code: int | None = None, error_code: str | None = None):
7
+ super().__init__(message)
8
+ self.status_code = status_code
9
+ self.error_code = error_code
10
+
11
+ def __repr__(self):
12
+ return f"{self.__class__.__name__}({self.args[0]!r}, status_code={self.status_code})"
13
+
14
+
15
+ class AuthError(NeedleSearchError):
16
+ """API key missing, invalid, revoked, or expired."""
17
+
18
+
19
+ class InsufficientScopeError(NeedleSearchError):
20
+ """API key lacks the required scope for this operation."""
21
+
22
+
23
+ class RateLimitError(NeedleSearchError):
24
+ """Rate limit exceeded. Check retry_after for seconds to wait."""
25
+ def __init__(self, message: str, retry_after: int = 60):
26
+ super().__init__(message, status_code=429, error_code="rate_limit_exceeded")
27
+ self.retry_after = retry_after
28
+
29
+
30
+ class QuotaError(NeedleSearchError):
31
+ """Monthly request quota exceeded."""
32
+ def __init__(self, message: str, used: int | None = None, limit: int | None = None):
33
+ super().__init__(message, status_code=429, error_code="monthly_quota_exceeded")
34
+ self.used = used
35
+ self.limit = limit
36
+
37
+
38
+ class NotFoundError(NeedleSearchError):
39
+ """Requested resource not found."""
40
+
41
+
42
+ class ServerError(NeedleSearchError):
43
+ """Internal server error."""
44
+
45
+
46
+ class DocumentNotReadyError(NeedleSearchError):
47
+ """Document is still being processed."""