openscour 0.3.1__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.
openscour/__init__.py ADDED
@@ -0,0 +1,38 @@
1
+ """OpenScour: self-hosted web search for LLMs and agents.
2
+
3
+ from openscour import OpenScour
4
+
5
+ client = OpenScour(api_key="YOUR_API_KEY")
6
+ results = client.search("Latest AWS Bedrock updates", max_results=10)
7
+ """
8
+
9
+ from openscour.client import AsyncOpenScour, OpenScour
10
+ from openscour.errors import (
11
+ APIConnectionError,
12
+ APIStatusError,
13
+ AuthenticationError,
14
+ InvalidRequestError,
15
+ NoResultsError,
16
+ OpenScourError,
17
+ RateLimitError,
18
+ )
19
+ from openscour.types import ExtractResponse, ExtractResult, FailedResult, SearchResponse, SearchResult
20
+
21
+ __version__ = "0.3.1"
22
+
23
+ __all__ = [
24
+ "OpenScour",
25
+ "AsyncOpenScour",
26
+ "SearchResponse",
27
+ "SearchResult",
28
+ "ExtractResponse",
29
+ "ExtractResult",
30
+ "FailedResult",
31
+ "OpenScourError",
32
+ "APIConnectionError",
33
+ "APIStatusError",
34
+ "AuthenticationError",
35
+ "InvalidRequestError",
36
+ "NoResultsError",
37
+ "RateLimitError",
38
+ ]
openscour/client.py ADDED
@@ -0,0 +1,267 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import os
5
+ import random
6
+ import time
7
+ from typing import Any, Literal
8
+
9
+ import httpx
10
+
11
+ from openscour.errors import (
12
+ APIConnectionError,
13
+ APIStatusError,
14
+ AuthenticationError,
15
+ InvalidRequestError,
16
+ NoResultsError,
17
+ RateLimitError,
18
+ )
19
+ from openscour.types import ExtractResponse, SearchResponse
20
+
21
+ DEFAULT_BASE_URL = "http://localhost:8000"
22
+ DEFAULT_TIMEOUT = 60.0
23
+ DEFAULT_MAX_RETRIES = 2
24
+ # Longest wait between retries; a longer Retry-After is raised to the caller instead.
25
+ MAX_RETRY_WAIT = 30.0
26
+
27
+ # Connection failures that happen before the server could act on the request.
28
+ # Read timeouts are not retried: the search may still be running.
29
+ _RETRYABLE_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout, httpx.RemoteProtocolError)
30
+
31
+ # Module-level so tests can skip the waiting.
32
+ _sleep = time.sleep
33
+ _async_sleep = asyncio.sleep
34
+
35
+ SearchDepth = Literal["basic", "advanced"]
36
+ Topic = Literal["general", "news"]
37
+ TimeRange = Literal["day", "week", "month", "year"]
38
+
39
+
40
+ def _search_body(
41
+ query: str,
42
+ search_depth: SearchDepth,
43
+ topic: Topic,
44
+ max_results: int,
45
+ include_answer: bool,
46
+ include_raw_content: bool,
47
+ include_domains: list[str] | None,
48
+ exclude_domains: list[str] | None,
49
+ time_range: TimeRange | None,
50
+ ) -> dict[str, Any]:
51
+ return {
52
+ "query": query,
53
+ "search_depth": search_depth,
54
+ "topic": topic,
55
+ "max_results": max_results,
56
+ "include_answer": include_answer,
57
+ "include_raw_content": include_raw_content,
58
+ "include_domains": include_domains or [],
59
+ "exclude_domains": exclude_domains or [],
60
+ "time_range": time_range,
61
+ }
62
+
63
+
64
+ def _extract_body(urls: str | list[str], query: str | None, chunks_per_source: int) -> dict[str, Any]:
65
+ body: dict[str, Any] = {"urls": [urls] if isinstance(urls, str) else urls}
66
+ if query:
67
+ body["query"] = query
68
+ body["chunks_per_source"] = chunks_per_source
69
+ return body
70
+
71
+
72
+ def _is_json(resp: httpx.Response) -> bool:
73
+ return resp.headers.get("content-type", "").startswith("application/json")
74
+
75
+
76
+ def _retry_after(resp: httpx.Response) -> float | None:
77
+ try:
78
+ return max(0.0, float(resp.headers["retry-after"]))
79
+ except (KeyError, ValueError):
80
+ return None
81
+
82
+
83
+ def _retry_wait(attempt: int, resp: httpx.Response | None = None) -> float | None:
84
+ """Seconds to wait before retrying, or None if this outcome should not be retried."""
85
+ if resp is not None:
86
+ # OpenScour's own 502 means no search results, which a retry won't change;
87
+ # a 502 page from a proxy in front of it is transient.
88
+ transient = resp.status_code in (429, 503, 504) or (resp.status_code == 502 and not _is_json(resp))
89
+ if not transient:
90
+ return None
91
+ if (wait := _retry_after(resp)) is not None:
92
+ return wait if wait <= MAX_RETRY_WAIT else None
93
+ # Exponential backoff with jitter, so many clients don't retry in step.
94
+ return min(MAX_RETRY_WAIT, 0.5 * 2**attempt) * random.uniform(0.5, 1.0)
95
+
96
+
97
+ def _raise_for_status(resp: httpx.Response) -> None:
98
+ if resp.is_success:
99
+ return
100
+ try:
101
+ body = resp.json()
102
+ detail = body.get("detail", body) if isinstance(body, dict) else body
103
+ except ValueError:
104
+ body = detail = resp.text
105
+ message = detail if isinstance(detail, str) else str(detail)
106
+ if resp.status_code == 429:
107
+ raise RateLimitError(message, 429, body, retry_after=_retry_after(resp))
108
+ if resp.status_code == 502 and not _is_json(resp):
109
+ raise APIStatusError(message, 502, body)
110
+ error = {401: AuthenticationError, 422: InvalidRequestError, 502: NoResultsError}.get(
111
+ resp.status_code, APIStatusError
112
+ )
113
+ raise error(message, resp.status_code, body)
114
+
115
+
116
+ class _BaseClient:
117
+ def __init__(self, api_key: str | None, base_url: str | None, max_retries: int):
118
+ self.max_retries = max_retries
119
+ self.api_key = api_key if api_key is not None else os.environ.get("OPENSCOUR_API_KEY")
120
+ self.base_url = (base_url or os.environ.get("OPENSCOUR_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
121
+
122
+ def _headers(self) -> dict[str, str]:
123
+ return {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {}
124
+
125
+
126
+ class OpenScour(_BaseClient):
127
+ """Client for an OpenScour server.
128
+
129
+ >>> client = OpenScour(api_key="...", base_url="http://localhost:8000")
130
+ >>> response = client.search("Latest AWS Bedrock updates", max_results=10)
131
+ >>> response.results[0].content
132
+
133
+ ``api_key`` and ``base_url`` default to the ``OPENSCOUR_API_KEY`` and
134
+ ``OPENSCOUR_BASE_URL`` environment variables. Requests that fail to connect
135
+ or get 429, 503 or 504 are retried up to ``max_retries`` times, honoring the
136
+ server's Retry-After.
137
+ """
138
+
139
+ def __init__(
140
+ self,
141
+ api_key: str | None = None,
142
+ base_url: str | None = None,
143
+ timeout: float = DEFAULT_TIMEOUT,
144
+ http_client: httpx.Client | None = None,
145
+ max_retries: int = DEFAULT_MAX_RETRIES,
146
+ ):
147
+ super().__init__(api_key, base_url, max_retries)
148
+ self._client = http_client or httpx.Client(timeout=timeout)
149
+
150
+ def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
151
+ attempt = 0
152
+ while True:
153
+ try:
154
+ resp = self._client.post(f"{self.base_url}{path}", json=body, headers=self._headers())
155
+ except httpx.HTTPError as e:
156
+ if isinstance(e, _RETRYABLE_ERRORS) and attempt < self.max_retries:
157
+ _sleep(_retry_wait(attempt))
158
+ attempt += 1
159
+ continue
160
+ raise APIConnectionError(f"Could not reach OpenScour at {self.base_url}: {e}") from e
161
+ if attempt < self.max_retries and (wait := _retry_wait(attempt, resp)) is not None:
162
+ _sleep(wait)
163
+ attempt += 1
164
+ continue
165
+ _raise_for_status(resp)
166
+ return resp.json()
167
+
168
+ def search(
169
+ self,
170
+ query: str,
171
+ *,
172
+ search_depth: SearchDepth = "basic",
173
+ topic: Topic = "general",
174
+ max_results: int = 5,
175
+ include_answer: bool = False,
176
+ include_raw_content: bool = False,
177
+ include_domains: list[str] | None = None,
178
+ exclude_domains: list[str] | None = None,
179
+ time_range: TimeRange | None = None,
180
+ ) -> SearchResponse:
181
+ body = _search_body(
182
+ query, search_depth, topic, max_results, include_answer,
183
+ include_raw_content, include_domains, exclude_domains, time_range,
184
+ ) # fmt: skip
185
+ return SearchResponse.from_dict(self._post("/search", body))
186
+
187
+ def extract(
188
+ self, urls: str | list[str], *, query: str | None = None, chunks_per_source: int = 3
189
+ ) -> ExtractResponse:
190
+ """Clean text of each page; with ``query``, only its most relevant passages."""
191
+ return ExtractResponse.from_dict(self._post("/extract", _extract_body(urls, query, chunks_per_source)))
192
+
193
+ def close(self) -> None:
194
+ self._client.close()
195
+
196
+ def __enter__(self) -> OpenScour:
197
+ return self
198
+
199
+ def __exit__(self, *exc: object) -> None:
200
+ self.close()
201
+
202
+
203
+ class AsyncOpenScour(_BaseClient):
204
+ """Async version of :class:`OpenScour`; same methods, awaited."""
205
+
206
+ def __init__(
207
+ self,
208
+ api_key: str | None = None,
209
+ base_url: str | None = None,
210
+ timeout: float = DEFAULT_TIMEOUT,
211
+ http_client: httpx.AsyncClient | None = None,
212
+ max_retries: int = DEFAULT_MAX_RETRIES,
213
+ ):
214
+ super().__init__(api_key, base_url, max_retries)
215
+ self._client = http_client or httpx.AsyncClient(timeout=timeout)
216
+
217
+ async def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
218
+ attempt = 0
219
+ while True:
220
+ try:
221
+ resp = await self._client.post(f"{self.base_url}{path}", json=body, headers=self._headers())
222
+ except httpx.HTTPError as e:
223
+ if isinstance(e, _RETRYABLE_ERRORS) and attempt < self.max_retries:
224
+ await _async_sleep(_retry_wait(attempt))
225
+ attempt += 1
226
+ continue
227
+ raise APIConnectionError(f"Could not reach OpenScour at {self.base_url}: {e}") from e
228
+ if attempt < self.max_retries and (wait := _retry_wait(attempt, resp)) is not None:
229
+ await _async_sleep(wait)
230
+ attempt += 1
231
+ continue
232
+ _raise_for_status(resp)
233
+ return resp.json()
234
+
235
+ async def search(
236
+ self,
237
+ query: str,
238
+ *,
239
+ search_depth: SearchDepth = "basic",
240
+ topic: Topic = "general",
241
+ max_results: int = 5,
242
+ include_answer: bool = False,
243
+ include_raw_content: bool = False,
244
+ include_domains: list[str] | None = None,
245
+ exclude_domains: list[str] | None = None,
246
+ time_range: TimeRange | None = None,
247
+ ) -> SearchResponse:
248
+ body = _search_body(
249
+ query, search_depth, topic, max_results, include_answer,
250
+ include_raw_content, include_domains, exclude_domains, time_range,
251
+ ) # fmt: skip
252
+ return SearchResponse.from_dict(await self._post("/search", body))
253
+
254
+ async def extract(
255
+ self, urls: str | list[str], *, query: str | None = None, chunks_per_source: int = 3
256
+ ) -> ExtractResponse:
257
+ """Clean text of each page; with ``query``, only its most relevant passages."""
258
+ return ExtractResponse.from_dict(await self._post("/extract", _extract_body(urls, query, chunks_per_source)))
259
+
260
+ async def close(self) -> None:
261
+ await self._client.aclose()
262
+
263
+ async def __aenter__(self) -> AsyncOpenScour:
264
+ return self
265
+
266
+ async def __aexit__(self, *exc: object) -> None:
267
+ await self.close()
openscour/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ class OpenScourError(Exception):
2
+ """Base class for all errors raised by the OpenScour client."""
3
+
4
+
5
+ class APIConnectionError(OpenScourError):
6
+ """The OpenScour server could not be reached."""
7
+
8
+
9
+ class APIStatusError(OpenScourError):
10
+ """The server returned an error response."""
11
+
12
+ def __init__(self, message: str, status_code: int, body: object = None):
13
+ super().__init__(f"{status_code}: {message}")
14
+ self.status_code = status_code
15
+ self.body = body
16
+
17
+
18
+ class AuthenticationError(APIStatusError):
19
+ """The API key is missing or invalid (HTTP 401)."""
20
+
21
+
22
+ class InvalidRequestError(APIStatusError):
23
+ """The request was rejected, e.g. an empty query (HTTP 422)."""
24
+
25
+
26
+ class NoResultsError(APIStatusError):
27
+ """No search backend returned results (HTTP 502)."""
28
+
29
+
30
+ class RateLimitError(APIStatusError):
31
+ """Too many requests (HTTP 429). ``retry_after`` is the seconds the server asked to wait, if it said."""
32
+
33
+ def __init__(self, message: str, status_code: int, body: object = None, retry_after: float | None = None):
34
+ super().__init__(message, status_code, body)
35
+ self.retry_after = retry_after
@@ -0,0 +1,161 @@
1
+ """MCP server that gives AI agents OpenScour's search and extract as tools.
2
+
3
+ Runs over stdio and talks to an OpenScour server through the Python client,
4
+ configured with OPENSCOUR_BASE_URL and OPENSCOUR_API_KEY.
5
+
6
+ claude mcp add openscour -- openscour-mcp
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ from collections.abc import AsyncIterator
13
+ from contextlib import asynccontextmanager
14
+ from typing import Literal
15
+
16
+ from mcp.server.mcpserver import MCPServer
17
+ from mcp.server.mcpserver.exceptions import ToolError
18
+ from mcp.types import ToolAnnotations
19
+
20
+ from openscour.client import AsyncOpenScour
21
+ from openscour.errors import APIConnectionError, OpenScourError
22
+ from openscour.types import ExtractResponse, SearchResponse
23
+
24
+ INSTRUCTIONS = (
25
+ "OpenScour searches the web and returns ranked passages from real pages. "
26
+ "Use `search` for current events, recent releases, facts you are unsure of, "
27
+ "or anything after your training data. Use `extract` to read the full text "
28
+ "of specific URLs. Cite the URLs of the passages you rely on."
29
+ )
30
+
31
+ # Upper bound on extracted page text per URL, so one long page can't flood the context.
32
+ MAX_EXTRACT_CHARS = 20_000
33
+
34
+ server = MCPServer(name="openscour", instructions=INSTRUCTIONS)
35
+
36
+ _READ_ONLY_WEB = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
37
+
38
+
39
+ # One client for the session, so tool calls reuse open connections. Tied to the
40
+ # event loop it was made on, since an async HTTP client can't move between loops.
41
+ _shared: tuple[asyncio.AbstractEventLoop, AsyncOpenScour] | None = None
42
+
43
+
44
+ def _shared_client() -> AsyncOpenScour:
45
+ global _shared
46
+ loop = asyncio.get_running_loop()
47
+ if _shared is None or _shared[0] is not loop:
48
+ _shared = (loop, AsyncOpenScour())
49
+ return _shared[1]
50
+
51
+
52
+ @asynccontextmanager
53
+ async def _client() -> AsyncIterator[AsyncOpenScour]:
54
+ """The shared OpenScour client, with errors that reach the agent as readable tool errors.
55
+
56
+ Other exceptions only show the agent "Error executing tool", which gives it
57
+ nothing to act on (e.g. that the OpenScour server isn't running).
58
+ """
59
+ try:
60
+ yield _shared_client()
61
+ except APIConnectionError as e:
62
+ raise ToolError(f"{e}. Is the OpenScour server running?") from e
63
+ except OpenScourError as e:
64
+ raise ToolError(str(e)) from e
65
+
66
+
67
+ def format_search(response: SearchResponse) -> str:
68
+ if not response.results:
69
+ return f"No results for {response.query!r}."
70
+ parts = []
71
+ if response.answer:
72
+ parts.append(f"Answer: {response.answer}")
73
+ for i, r in enumerate(response.results, start=1):
74
+ lines = [f"[{i}] {r.title}", f"URL: {r.url}"]
75
+ if r.published_date:
76
+ lines.append(f"Published: {r.published_date}")
77
+ lines.append(r.content)
78
+ if r.raw_content:
79
+ lines.append(f"Full text:\n{r.raw_content[:MAX_EXTRACT_CHARS]}")
80
+ parts.append("\n".join(lines))
81
+ return "\n\n".join(parts)
82
+
83
+
84
+ def format_extract(response: ExtractResponse) -> str:
85
+ parts = []
86
+ for r in response.results:
87
+ text = r.raw_content
88
+ if len(text) > MAX_EXTRACT_CHARS:
89
+ text = text[:MAX_EXTRACT_CHARS] + f"\n[... truncated, {len(r.raw_content)} characters in total]"
90
+ parts.append(f"URL: {r.url}\n{text}")
91
+ for f in response.failed_results:
92
+ parts.append(f"URL: {f.url}\nCould not extract: {f.error}")
93
+ return "\n\n---\n\n".join(parts) or "No URLs were extracted."
94
+
95
+
96
+ @server.tool(
97
+ description=(
98
+ "Search the web and get the most relevant passage from each result page. "
99
+ "Use for current or factual information. Results are numbered with title, URL and passage."
100
+ ),
101
+ annotations=_READ_ONLY_WEB,
102
+ )
103
+ async def search(
104
+ query: str,
105
+ max_results: int = 5,
106
+ search_depth: Literal["basic", "advanced"] = "basic",
107
+ topic: Literal["general", "news"] = "general",
108
+ time_range: Literal["day", "week", "month", "year"] | None = None,
109
+ include_domains: list[str] | None = None,
110
+ exclude_domains: list[str] | None = None,
111
+ ) -> str:
112
+ """
113
+ Args:
114
+ query: What to search for, e.g. "Latest AWS Bedrock updates".
115
+ max_results: Number of results, 1 to 20.
116
+ search_depth: "advanced" reads more pages and returns more passages per result; slower.
117
+ topic: "news" for recent news coverage.
118
+ time_range: Only results from the last day, week, month or year.
119
+ include_domains: Only return results from these domains.
120
+ exclude_domains: Never return results from these domains.
121
+ """
122
+ async with _client() as client:
123
+ response = await client.search(
124
+ query,
125
+ max_results=max_results,
126
+ search_depth=search_depth,
127
+ topic=topic,
128
+ time_range=time_range,
129
+ include_domains=include_domains,
130
+ exclude_domains=exclude_domains,
131
+ )
132
+ return format_search(response)
133
+
134
+
135
+ @server.tool(
136
+ description=(
137
+ "Read web pages. Pass `query` with what you are looking for to get only the most "
138
+ "relevant passages of each page (short, recommended); without it you get the full "
139
+ "page text, which can be very long."
140
+ ),
141
+ annotations=_READ_ONLY_WEB,
142
+ )
143
+ async def extract(urls: list[str], query: str | None = None, chunks_per_source: int = 3) -> str:
144
+ """
145
+ Args:
146
+ urls: Up to 20 http(s) URLs to read.
147
+ query: What you need from the pages, e.g. "new Bedrock models in October 2026".
148
+ chunks_per_source: With a query, how many passages to return per page (1 to 10).
149
+ """
150
+ async with _client() as client:
151
+ response = await client.extract(urls, query=query, chunks_per_source=chunks_per_source)
152
+ return format_extract(response)
153
+
154
+
155
+ def main() -> None:
156
+ """Entry point for the ``openscour-mcp`` command."""
157
+ server.run("stdio")
158
+
159
+
160
+ if __name__ == "__main__":
161
+ main()
openscour/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,56 @@
1
+ """Optional answer synthesis with Claude (Amazon Bedrock or the Claude API)."""
2
+
3
+ import logging
4
+ from functools import lru_cache
5
+
6
+ import anthropic
7
+
8
+ from openscour.server.config import settings
9
+ from openscour.server.ranker import Ranked
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ SYSTEM = (
14
+ "You answer a search query using only the numbered sources provided. "
15
+ "Write a direct, factual answer in 1-4 sentences. Cite sources inline as [1], [2]. "
16
+ "If the sources do not contain the answer, say so briefly."
17
+ )
18
+
19
+
20
+ @lru_cache(maxsize=1)
21
+ def _client():
22
+ if settings.answer_provider == "bedrock":
23
+ return anthropic.AsyncAnthropicBedrockMantle(aws_region=settings.aws_region)
24
+ return anthropic.AsyncAnthropic()
25
+
26
+
27
+ def _model() -> str:
28
+ m = settings.answer_model
29
+ if settings.answer_provider == "bedrock" and not m.startswith("anthropic."):
30
+ return f"anthropic.{m}"
31
+ return m
32
+
33
+
34
+ async def generate_answer(query: str, results: list[Ranked]) -> str | None:
35
+ if settings.answer_provider == "none" or not results:
36
+ return None
37
+
38
+ sources = "\n\n".join(
39
+ f"[{i}] {r.doc.title}\n{r.doc.url}\n{r.content}" for i, r in enumerate(results, start=1)
40
+ )
41
+ messages = [{"role": "user", "content": f"Sources:\n\n{sources}\n\nQuery: {query}"}]
42
+ try:
43
+ response = await _client().messages.create(
44
+ model=_model(),
45
+ max_tokens=8000,
46
+ system=SYSTEM,
47
+ output_config={"effort": "low"},
48
+ messages=messages,
49
+ )
50
+ except anthropic.APIError as e:
51
+ log.warning("answer generation failed: %s", e)
52
+ return None
53
+
54
+ if response.stop_reason == "refusal":
55
+ return None
56
+ return "".join(b.text for b in response.content if b.type == "text").strip() or None
@@ -0,0 +1,33 @@
1
+ import time
2
+ from collections import OrderedDict
3
+ from typing import Any
4
+
5
+
6
+ class TTLCache:
7
+ """Small in-process LRU cache with per-entry expiry.
8
+
9
+ Good enough for a single container. Swap for Redis (ElastiCache) once
10
+ you run more than one API task.
11
+ """
12
+
13
+ def __init__(self, ttl_seconds: int, max_items: int = 2048):
14
+ self.ttl = ttl_seconds
15
+ self.max_items = max_items
16
+ self._data: OrderedDict[str, tuple[float, Any]] = OrderedDict()
17
+
18
+ def get(self, key: str) -> Any | None:
19
+ item = self._data.get(key)
20
+ if item is None:
21
+ return None
22
+ expires_at, value = item
23
+ if expires_at < time.monotonic():
24
+ del self._data[key]
25
+ return None
26
+ self._data.move_to_end(key)
27
+ return value
28
+
29
+ def set(self, key: str, value: Any) -> None:
30
+ self._data[key] = (time.monotonic() + self.ttl, value)
31
+ self._data.move_to_end(key)
32
+ while len(self._data) > self.max_items:
33
+ self._data.popitem(last=False)
@@ -0,0 +1,59 @@
1
+ from typing import Literal
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+ from openscour import __version__
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
10
+
11
+ # Comma-separated list of keys clients must send as "Authorization: Bearer <key>".
12
+ # Leave empty to disable auth (local development).
13
+ api_keys: str = ""
14
+
15
+ searxng_url: str = "http://localhost:8888"
16
+ # Used only when SearXNG errors out or returns nothing.
17
+ duckduckgo_fallback: bool = True
18
+
19
+ # include_answer backend. "none" makes include_answer return null.
20
+ answer_provider: Literal["none", "bedrock", "anthropic"] = "none"
21
+ answer_model: str = "claude-opus-5-5"
22
+ aws_region: str = "us-east-1"
23
+
24
+ fetch_timeout: float = 8.0
25
+ fetch_concurrency: int = 10
26
+ extract_workers: int = 4
27
+ # Basic searches answer after this many seconds and use search snippets for
28
+ # pages that aren't ready; those pages keep loading in the background for the cache.
29
+ basic_fetch_deadline: float = 2.0
30
+ max_page_bytes: int = 3_000_000
31
+ cache_ttl_seconds: int = 900
32
+ # Pages being fetched at once across all requests; more wait their turn.
33
+ # Requests beyond max_inflight_fetches use search snippets instead.
34
+ max_inflight_fetches: int = 200
35
+ # Extraction workers are replaced after this many pages, so memory
36
+ # fragmentation in lxml can't build up in a long-running server.
37
+ extract_max_tasks_per_child: int = 500
38
+
39
+ # Identify honestly to the sites OpenScour reads, and follow their robots.txt.
40
+ user_agent: str = f"OpenScour/{__version__} (+https://github.com/ivipinmishra/openscour)"
41
+ respect_robots_txt: bool = True
42
+ # Pages are only fetched from public internet addresses (SSRF protection).
43
+ # Set to true only for a trusted, internal-only deployment that must read
44
+ # intranet pages; never on a server untrusted callers can reach.
45
+ allow_private_network_fetches: bool = False
46
+
47
+ # Requests per minute for each API key (or each client IP when auth is off),
48
+ # across /search and /extract. 0 turns rate limiting off.
49
+ rate_limit_per_minute: int = 60
50
+ # Take the client IP from the X-Forwarded-For header set by a load balancer.
51
+ # Only enable behind a proxy that sets it: clients can forge it otherwise.
52
+ trust_forwarded_for: bool = False
53
+
54
+ @property
55
+ def api_key_set(self) -> set[str]:
56
+ return {k.strip() for k in self.api_keys.split(",") if k.strip()}
57
+
58
+
59
+ settings = Settings()