Initial curated RAGcore source from c16667f0c9397e2bfc8dd0255b114fba23c8d05e

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:34:21 +02:00
commit 7ca726caf2
659 files changed
+98171

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
# ragcore-sdk
Typed Python client for RAGCore's `/v1/search`, `/v1/context`, and
`/v1/answers` endpoints.
```python
from ragcore_sdk import RAGCoreClient
with RAGCoreClient(base_url="https://ragcore.example.internal", token="...") as client:
response = client.search(query="What is the refund policy?")
for result in response.results:
print(result.rank, result.citation.source_uri)
```
Streaming answers:
```python
import asyncio
from ragcore_sdk import AsyncRAGCoreClient
async def main() -> None:
async with AsyncRAGCoreClient(base_url="https://ragcore.example.internal", token="...") as client:
async for event in client.answer_stream(query="What is the refund policy?"):
print(event.type, event.data)
asyncio.run(main())
```
## Scope
This SDK covers exactly the endpoints RAGCore actually implements:
`search`, `context`, and `answers` (sync and SSE-streaming). It does not
expose `get_citation`/`get_job` methods — those paths exist only in the
aspirational OpenAPI contract, not as real routes in this codebase, so
the SDK does not fabricate a client surface for them.
## Errors
Every non-2xx response RAGCore returns is an RFC7807 Problem Details body.
The SDK raises `RAGCoreAPIError` (carrying the parsed `ProblemDetails`) for
those, and `RAGCoreConnectionError` for network-level failures that
persist after retries are exhausted. Retries only ever apply to this
SDK's own read-only, side-effect-free requests (search/context/answers are
GET-equivalent queries) on connection failures and `503` responses --
never on `4xx` client errors.
The bearer token is never logged or included in exception messages.
+20
View File
@@ -0,0 +1,20 @@
[project]
name = "ragcore-sdk"
version = "1.2.1"
description = "Typed Python client for the RAGCore search/context/answers API"
readme = "README.md"
requires-python = ">=3.13,<3.14"
license = { text = "Proprietary" }
authors = [{ name = "ITWorx" }]
dependencies = [
"httpx>=0.28,<1",
"pydantic>=2.10,<3",
]
[build-system]
requires = ["uv_build>=0.8,<1"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "ragcore_sdk"
module-root = "src"
+43
View File
@@ -0,0 +1,43 @@
"""Typed Python client for RAGCore's search/context/answers API."""
from ragcore_sdk._version import __version__
from ragcore_sdk.client import AsyncRAGCoreClient, RAGCoreClient
from ragcore_sdk.errors import ProblemDetails, RAGCoreAPIError, RAGCoreConnectionError, RAGCoreError
from ragcore_sdk.models import (
AnswerabilityStatus,
AnswerClaim,
AnswerResponse,
AnswerStreamEvent,
AnswerStreamEventType,
Citation,
ContextBlock,
ContextResponse,
SearchFilter,
SearchResponse,
SearchResult,
SearchScores,
TrustLevel,
)
__all__ = [
"AnswerClaim",
"AnswerResponse",
"AnswerStreamEvent",
"AnswerStreamEventType",
"AnswerabilityStatus",
"AsyncRAGCoreClient",
"Citation",
"ContextBlock",
"ContextResponse",
"ProblemDetails",
"RAGCoreAPIError",
"RAGCoreClient",
"RAGCoreConnectionError",
"RAGCoreError",
"SearchFilter",
"SearchResponse",
"SearchResult",
"SearchScores",
"TrustLevel",
"__version__",
]
+8
View File
@@ -0,0 +1,8 @@
"""Single source of truth for the SDK's own version string.
A separate module (not `__init__.py`) so `client.py` can import it without
a circular import back through the package's own `__init__.py`, which
itself imports from `client.py`.
"""
__version__ = "1.2.1"
+460
View File
@@ -0,0 +1,460 @@
"""Synchronous and asynchronous HTTP clients for RAGCore's real, implemented
`/v1/search`, `/v1/context`, and `/v1/answers` routes.
Retries only ever apply to the non-streaming request path, and only on
connection failures or a `503` response -- these three endpoints are
read-only queries with no side effects, so replaying the whole request is
safe. A `4xx` response is never retried: retrying a request the server has
already rejected as invalid or unauthorized cannot succeed. A started SSE
stream (`answer_stream`) is never retried by this SDK -- once bytes have
been read from a partially-consumed stream, silently restarting it could
double-attribute events to the caller, so a failed stream surfaces
immediately instead.
"""
from __future__ import annotations
import asyncio
import json
import time
from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator, Sequence
from types import TracebackType
from typing import Any
from uuid import UUID
import httpx
from ragcore_sdk._version import __version__
from ragcore_sdk.errors import ProblemDetails, RAGCoreAPIError, RAGCoreConnectionError
from ragcore_sdk.models import (
AnswerResponse,
AnswerStreamEvent,
AnswerStreamEventType,
ContextResponse,
SearchFilter,
SearchResponse,
)
DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0)
_USER_AGENT = f"ragcore-sdk-python/{__version__}"
_RETRYABLE_STATUS = {503}
_DEFAULT_MAX_RETRIES = 3
_BACKOFF_BASE_SECONDS = 0.5
def _is_retryable(status_code: int) -> bool:
return status_code in _RETRYABLE_STATUS
def _search_body(
query: str,
*,
requested_space_ids: Sequence[UUID],
profile: str,
max_results: int,
filters: SearchFilter | None,
include_debug: bool,
max_context_tokens: int | None = None,
generation_profile: str | None = None,
stream: bool | None = None,
) -> dict[str, object]:
body: dict[str, object] = {
"query": query,
"requested_space_ids": [str(space_id) for space_id in requested_space_ids],
"profile": profile,
"max_results": max_results,
"include_debug": include_debug,
}
if filters is not None:
body["filters"] = filters.model_dump(mode="json", exclude_none=True)
if max_context_tokens is not None:
body["max_context_tokens"] = max_context_tokens
if generation_profile is not None:
body["generation_profile"] = generation_profile
if stream is not None:
body["stream"] = stream
return body
def _problem_from_response(response: httpx.Response) -> ProblemDetails:
try:
payload: dict[str, Any] = response.json()
except ValueError:
payload = {
"type": "about:blank",
"title": "Error",
"status": response.status_code,
"code": "NON_PROBLEM_RESPONSE",
"trace_id": "",
}
return ProblemDetails.from_json(payload)
def _raise_for_problem(response: httpx.Response) -> None:
if response.status_code < 400:
return
raise RAGCoreAPIError(_problem_from_response(response))
def _parse_sse_lines(lines: Iterable[str]) -> Iterator[AnswerStreamEvent]:
event_type: str | None = None
data_lines: list[str] = []
for line in lines:
if line == "":
if event_type is not None:
data = json.loads("".join(data_lines)) if data_lines else {}
yield AnswerStreamEvent(type=AnswerStreamEventType(event_type), data=data)
event_type, data_lines = None, []
continue
if line.startswith("event:"):
event_type = line.removeprefix("event:").strip()
elif line.startswith("data:"):
data_lines.append(line.removeprefix("data:").strip())
if event_type is not None and data_lines:
data = json.loads("".join(data_lines))
yield AnswerStreamEvent(type=AnswerStreamEventType(event_type), data=data)
async def _parse_sse_lines_async(lines: AsyncIterable[str]) -> AsyncIterator[AnswerStreamEvent]:
event_type: str | None = None
data_lines: list[str] = []
async for line in lines:
if line == "":
if event_type is not None:
data = json.loads("".join(data_lines)) if data_lines else {}
yield AnswerStreamEvent(type=AnswerStreamEventType(event_type), data=data)
event_type, data_lines = None, []
continue
if line.startswith("event:"):
event_type = line.removeprefix("event:").strip()
elif line.startswith("data:"):
data_lines.append(line.removeprefix("data:").strip())
if event_type is not None and data_lines:
data = json.loads("".join(data_lines))
yield AnswerStreamEvent(type=AnswerStreamEventType(event_type), data=data)
class RAGCoreClient:
"""Synchronous RAGCore client for `/v1/search`, `/v1/context`, `/v1/answers`."""
def __init__(
self,
base_url: str,
token: str,
*,
timeout: httpx.Timeout = DEFAULT_TIMEOUT,
max_retries: int = _DEFAULT_MAX_RETRIES,
transport: httpx.BaseTransport | None = None,
) -> None:
"""`transport` is a test/advanced-usage hook (e.g. `httpx.ASGITransport`
to point this client at an in-process app with no real network hop);
production callers should leave it unset.
"""
self._max_retries = max_retries
self._client = httpx.Client(
base_url=base_url,
timeout=timeout,
headers={"authorization": f"Bearer {token}", "user-agent": _USER_AGENT},
transport=transport,
)
def __enter__(self) -> RAGCoreClient:
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
self.close()
def close(self) -> None:
self._client.close()
def __repr__(self) -> str:
return f"RAGCoreClient(base_url={self._client.base_url!r})"
def _post(self, path: str, body: dict[str, object]) -> httpx.Response:
attempt = 0
while True:
try:
response = self._client.post(path, json=body)
except httpx.TransportError as error:
if attempt >= self._max_retries:
raise RAGCoreConnectionError(type(error).__name__) from error
else:
if not _is_retryable(response.status_code) or attempt >= self._max_retries:
return response
time.sleep(_BACKOFF_BASE_SECONDS * (2**attempt))
attempt += 1
def search(
self,
query: str,
*,
requested_space_ids: Sequence[UUID] = (),
profile: str = "hybrid-v1",
max_results: int = 10,
filters: SearchFilter | None = None,
include_debug: bool = False,
) -> SearchResponse:
body = _search_body(
query,
requested_space_ids=requested_space_ids,
profile=profile,
max_results=max_results,
filters=filters,
include_debug=include_debug,
)
response = self._post("/v1/search", body)
_raise_for_problem(response)
return SearchResponse.model_validate(response.json())
def context(
self,
query: str,
*,
requested_space_ids: Sequence[UUID] = (),
profile: str = "hybrid-v1",
max_results: int = 10,
filters: SearchFilter | None = None,
include_debug: bool = False,
max_context_tokens: int = 8000,
) -> ContextResponse:
body = _search_body(
query,
requested_space_ids=requested_space_ids,
profile=profile,
max_results=max_results,
filters=filters,
include_debug=include_debug,
max_context_tokens=max_context_tokens,
)
response = self._post("/v1/context", body)
_raise_for_problem(response)
return ContextResponse.model_validate(response.json())
def answer(
self,
query: str,
*,
requested_space_ids: Sequence[UUID] = (),
profile: str = "hybrid-v1",
max_results: int = 10,
filters: SearchFilter | None = None,
include_debug: bool = False,
max_context_tokens: int = 8000,
generation_profile: str = "answer-local-balanced-v1",
) -> AnswerResponse:
body = _search_body(
query,
requested_space_ids=requested_space_ids,
profile=profile,
max_results=max_results,
filters=filters,
include_debug=include_debug,
max_context_tokens=max_context_tokens,
generation_profile=generation_profile,
stream=False,
)
response = self._post("/v1/answers", body)
_raise_for_problem(response)
return AnswerResponse.model_validate(response.json())
def answer_stream(
self,
query: str,
*,
requested_space_ids: Sequence[UUID] = (),
profile: str = "hybrid-v1",
max_results: int = 10,
filters: SearchFilter | None = None,
include_debug: bool = False,
max_context_tokens: int = 8000,
generation_profile: str = "answer-local-balanced-v1",
) -> Iterator[AnswerStreamEvent]:
body = _search_body(
query,
requested_space_ids=requested_space_ids,
profile=profile,
max_results=max_results,
filters=filters,
include_debug=include_debug,
max_context_tokens=max_context_tokens,
generation_profile=generation_profile,
stream=True,
)
try:
with self._client.stream("POST", "/v1/answers", json=body) as response:
if response.status_code >= 400:
response.read()
_raise_for_problem(response)
yield from _parse_sse_lines(response.iter_lines())
except httpx.TransportError as error:
raise RAGCoreConnectionError(type(error).__name__) from error
class AsyncRAGCoreClient:
"""Asynchronous RAGCore client for `/v1/search`, `/v1/context`, `/v1/answers`."""
def __init__(
self,
base_url: str,
token: str,
*,
timeout: httpx.Timeout = DEFAULT_TIMEOUT,
max_retries: int = _DEFAULT_MAX_RETRIES,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
"""`transport` is a test/advanced-usage hook (e.g. `httpx.ASGITransport`
to point this client at an in-process app with no real network hop);
production callers should leave it unset.
"""
self._max_retries = max_retries
self._client = httpx.AsyncClient(
base_url=base_url,
timeout=timeout,
headers={"authorization": f"Bearer {token}", "user-agent": _USER_AGENT},
transport=transport,
)
async def __aenter__(self) -> AsyncRAGCoreClient:
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> None:
await self.aclose()
async def aclose(self) -> None:
await self._client.aclose()
def __repr__(self) -> str:
return f"AsyncRAGCoreClient(base_url={self._client.base_url!r})"
async def _post(self, path: str, body: dict[str, object]) -> httpx.Response:
attempt = 0
while True:
try:
response = await self._client.post(path, json=body)
except httpx.TransportError as error:
if attempt >= self._max_retries:
raise RAGCoreConnectionError(type(error).__name__) from error
else:
if not _is_retryable(response.status_code) or attempt >= self._max_retries:
return response
await asyncio.sleep(_BACKOFF_BASE_SECONDS * (2**attempt))
attempt += 1
async def search(
self,
query: str,
*,
requested_space_ids: Sequence[UUID] = (),
profile: str = "hybrid-v1",
max_results: int = 10,
filters: SearchFilter | None = None,
include_debug: bool = False,
) -> SearchResponse:
body = _search_body(
query,
requested_space_ids=requested_space_ids,
profile=profile,
max_results=max_results,
filters=filters,
include_debug=include_debug,
)
response = await self._post("/v1/search", body)
_raise_for_problem(response)
return SearchResponse.model_validate(response.json())
async def context(
self,
query: str,
*,
requested_space_ids: Sequence[UUID] = (),
profile: str = "hybrid-v1",
max_results: int = 10,
filters: SearchFilter | None = None,
include_debug: bool = False,
max_context_tokens: int = 8000,
) -> ContextResponse:
body = _search_body(
query,
requested_space_ids=requested_space_ids,
profile=profile,
max_results=max_results,
filters=filters,
include_debug=include_debug,
max_context_tokens=max_context_tokens,
)
response = await self._post("/v1/context", body)
_raise_for_problem(response)
return ContextResponse.model_validate(response.json())
async def answer(
self,
query: str,
*,
requested_space_ids: Sequence[UUID] = (),
profile: str = "hybrid-v1",
max_results: int = 10,
filters: SearchFilter | None = None,
include_debug: bool = False,
max_context_tokens: int = 8000,
generation_profile: str = "answer-local-balanced-v1",
) -> AnswerResponse:
body = _search_body(
query,
requested_space_ids=requested_space_ids,
profile=profile,
max_results=max_results,
filters=filters,
include_debug=include_debug,
max_context_tokens=max_context_tokens,
generation_profile=generation_profile,
stream=False,
)
response = await self._post("/v1/answers", body)
_raise_for_problem(response)
return AnswerResponse.model_validate(response.json())
async def answer_stream(
self,
query: str,
*,
requested_space_ids: Sequence[UUID] = (),
profile: str = "hybrid-v1",
max_results: int = 10,
filters: SearchFilter | None = None,
include_debug: bool = False,
max_context_tokens: int = 8000,
generation_profile: str = "answer-local-balanced-v1",
) -> AsyncIterator[AnswerStreamEvent]:
body = _search_body(
query,
requested_space_ids=requested_space_ids,
profile=profile,
max_results=max_results,
filters=filters,
include_debug=include_debug,
max_context_tokens=max_context_tokens,
generation_profile=generation_profile,
stream=True,
)
try:
async with self._client.stream("POST", "/v1/answers", json=body) as response:
if response.status_code >= 400:
await response.aread()
_raise_for_problem(response)
async for event in _parse_sse_lines_async(response.aiter_lines()):
yield event
except httpx.TransportError as error:
raise RAGCoreConnectionError(type(error).__name__) from error
__all__ = ["DEFAULT_TIMEOUT", "AsyncRAGCoreClient", "RAGCoreClient"]
+73
View File
@@ -0,0 +1,73 @@
"""Error types raised by the RAGCore SDK.
Every non-2xx response RAGCore returns is an RFC7807 `application/problem+json`
body shaped like `ragcore.api.v1.control.schemas.ProblemDetails`. The SDK
parses that body once, here, rather than leaving every call site to
re-parse raw JSON.
"""
from __future__ import annotations
from typing import Any
class RAGCoreError(Exception):
"""Base class for every error this SDK raises."""
class RAGCoreConnectionError(RAGCoreError):
"""A request never got a response, even after exhausting retries.
Never carries request headers or the auth token in its message.
"""
class ProblemDetails:
"""Parsed RFC7807 problem body. Deliberately a plain object, not a
pydantic model that could accidentally echo unexpected extra fields
(a server-side bug) back into a log line via `repr`.
"""
__slots__ = ("code", "detail", "errors", "status", "title", "trace_id", "type")
def __init__(
self,
*,
type: str,
title: str,
status: int,
code: str,
trace_id: str,
detail: str | None = None,
errors: list[dict[str, str]] | None = None,
) -> None:
self.type = type
self.title = title
self.status = status
self.code = code
self.trace_id = trace_id
self.detail = detail
self.errors = errors or []
@classmethod
def from_json(cls, body: dict[str, Any]) -> ProblemDetails:
return cls(
type=str(body.get("type", "about:blank")),
title=str(body.get("title", "Error")),
status=int(body.get("status", 0)),
code=str(body.get("code", "UNKNOWN")),
trace_id=str(body.get("trace_id", "")),
detail=body.get("detail"),
errors=body.get("errors"),
)
class RAGCoreAPIError(RAGCoreError):
"""RAGCore answered with a non-2xx `application/problem+json` response."""
def __init__(self, problem: ProblemDetails) -> None:
self.problem = problem
super().__init__(f"{problem.status} {problem.code}: {problem.detail or problem.title}")
__all__ = ["ProblemDetails", "RAGCoreAPIError", "RAGCoreConnectionError", "RAGCoreError"]
+179
View File
@@ -0,0 +1,179 @@
"""Typed request/response models mirroring RAGCore's real `/v1/{search,context,answers}`
Pydantic schemas (`ragcore.api.v1.search.schemas`, `.context.schemas`, `.answers.schemas`).
Kept in lockstep with the server by hand (this SDK is handwritten, not generated) --
field names and shapes here must match those modules exactly.
"""
from __future__ import annotations
from enum import StrEnum
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class TrustLevel(StrEnum):
TRUSTED_INTERNAL = "trusted_internal"
TRUSTED_EXTERNAL = "trusted_external"
UNVERIFIED = "unverified"
USER_UPLOADED = "user_uploaded"
INTERNET = "internet"
class AnswerabilityStatus(StrEnum):
ANSWERABLE = "answerable"
PARTIALLY_ANSWERABLE = "partially_answerable"
NOT_ANSWERABLE = "not_answerable"
CONFLICTING_EVIDENCE = "conflicting_evidence"
class SearchFilter(BaseModel):
model_config = ConfigDict(extra="forbid")
content_types: list[str] | None = None
languages: list[str] | None = None
source_ids: list[UUID] | None = None
document_ids: list[UUID] | None = None
trust_levels: list[TrustLevel] | None = None
class Citation(BaseModel):
model_config = ConfigDict(extra="forbid")
id: UUID
document_id: UUID
document_version_id: UUID
title: str
section: str | None = None
page_start: int | None = None
page_end: int | None = None
line_start: int | None = None
line_end: int | None = None
excerpt: str
excerpt_sha256: str
source_uri: str
# Provenance presentation fields (RAGCore >= 1.2.0); optional so the client
# still parses responses from older servers.
source_id: UUID | None = None
source_name: str | None = None
source_kind: str | None = None
locator: str | None = None
revision: str | None = None
display: str | None = None
class SearchScores(BaseModel):
model_config = ConfigDict(extra="forbid")
dense: float | None = None
sparse: float | None = None
fused: float | None = None
rerank: float | None = None
class SearchResult(BaseModel):
model_config = ConfigDict(extra="forbid")
chunk_id: UUID
parent_chunk_id: UUID | None = None
text: str
rank: int
scores: SearchScores
citation: Citation
class SearchResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
retrieval_run_id: UUID
effective_space_ids: list[UUID]
results: list[SearchResult]
degraded: bool
degradation_codes: list[str] = Field(default_factory=list)
timings_ms: dict[str, float] = Field(default_factory=dict)
class ContextBlock(BaseModel):
model_config = ConfigDict(extra="forbid")
text: str
citation_ids: list[UUID]
token_count: int
class ContextResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
retrieval_run_id: UUID
answerability: AnswerabilityStatus
answerability_reasons: list[str] = Field(default_factory=list)
blocks: list[ContextBlock] = Field(default_factory=list)
citations: list[Citation] = Field(default_factory=list)
token_count: int
degraded: bool = False
degradation_reasons: list[str] = Field(default_factory=list)
class AnswerClaim(BaseModel):
model_config = ConfigDict(extra="forbid")
text: str
citation_ids: list[UUID]
class AnswerResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
answer_id: UUID
retrieval_run_id: UUID
answerability: AnswerabilityStatus
answer: str
citations: list[Citation]
claims: list[AnswerClaim]
degraded: bool = False
degradation_reasons: list[str] = Field(default_factory=list)
class AnswerStreamEventType(StrEnum):
RETRIEVAL_METADATA = "retrieval_metadata"
DONE = "done"
ERROR = "error"
class AnswerStreamEvent(BaseModel):
"""One parsed SSE frame from a streaming `/v1/answers` call.
`data` is the raw decoded JSON payload -- its shape depends on `type`
(see `ragcore.api.v1.answers.service._metadata_event/_done_event/_error_event`
for the three real shapes). Use `as_done_answer()` for a typed view of a
`done` event rather than re-parsing `data` by hand.
"""
model_config = ConfigDict(extra="forbid")
type: AnswerStreamEventType
data: dict[str, Any]
def as_done_answer(self) -> AnswerResponse | None:
if self.type is not AnswerStreamEventType.DONE:
return None
return AnswerResponse.model_validate(self.data)
__all__ = [
"AnswerClaim",
"AnswerResponse",
"AnswerStreamEvent",
"AnswerStreamEventType",
"AnswerabilityStatus",
"Citation",
"ContextBlock",
"ContextResponse",
"SearchFilter",
"SearchResponse",
"SearchResult",
"SearchScores",
"TrustLevel",
]
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+1681
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@ragcore/sdk",
"version": "1.2.1",
"private": true,
"description": "Typed TypeScript client for RAGCore's search/context/answers API",
"type": "module",
"types": "./dist/index.d.ts",
"main": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"engines": {
"node": ">=18"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
+270
View File
@@ -0,0 +1,270 @@
/**
* A single client for RAGCore's real, implemented `/v1/search`,
* `/v1/context`, and `/v1/answers` routes. Uses global `fetch`, so it works
* unmodified in a browser or in Node (18+) -- no Node-only APIs.
*
* Unlike the Python SDK there's no separate sync/async pair: `fetch` is
* inherently async in both browser and Node, so one class covers both.
*
* Retries only ever apply to the non-streaming request path, and only on a
* network failure or a `503` response -- these three endpoints are
* read-only queries with no side effects, so replaying the whole request
* is safe. A `4xx` response is never retried. A started SSE stream
* (`answerStream`) is never retried by this SDK: once bytes have been read
* from a partially-consumed stream, silently restarting it could
* double-attribute events to the caller, so a failed stream surfaces
* immediately instead.
*
* The bearer token is held only in a true private class field (`#token`,
* not TypeScript's compile-time-only `private`), so it is never
* JSON-serializable, enumerable, or visible via `console.log`/spread on the
* client instance, and it is never written to `localStorage`,
* `sessionStorage`, or a cookie by this SDK.
*/
import { problemFromJson, RAGCoreAPIError, RAGCoreConnectionError } from "./errors.js";
import type {
AnswerResponse,
AnswerStreamEvent,
AnswerStreamEventType,
ContextResponse,
SearchFilter,
SearchResponse,
} from "./models.js";
const DEFAULT_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_RETRIES = 3;
const BACKOFF_BASE_MS = 500;
const RETRYABLE_STATUS = new Set([503]);
// Kept in sync with package.json's own "version" by hand (a small,
// hand-maintained SDK, not worth a build-time version-injection step).
// A custom header, not `User-Agent`: browsers silently drop attempts to
// set `User-Agent` via fetch, so it would work in Node but not browsers.
const _SDK_VERSION = "1.2.1";
export interface RAGCoreClientOptions {
timeoutMs?: number;
maxRetries?: number;
fetchImpl?: typeof fetch;
}
interface QueryOptions {
requestedSpaceIds?: string[];
profile?: string;
maxResults?: number;
filters?: SearchFilter;
includeDebug?: boolean;
}
interface ContextOptions extends QueryOptions {
maxContextTokens?: number;
}
interface AnswerOptions extends ContextOptions {
generationProfile?: string;
}
function searchBody(
query: string,
opts: QueryOptions & { maxContextTokens?: number; generationProfile?: string; stream?: boolean }
): Record<string, unknown> {
const body: Record<string, unknown> = {
query,
requested_space_ids: opts.requestedSpaceIds ?? [],
profile: opts.profile ?? "hybrid-v1",
max_results: opts.maxResults ?? 10,
include_debug: opts.includeDebug ?? false,
};
if (opts.filters !== undefined) {
body["filters"] = opts.filters;
}
if (opts.maxContextTokens !== undefined) {
body["max_context_tokens"] = opts.maxContextTokens;
}
if (opts.generationProfile !== undefined) {
body["generation_profile"] = opts.generationProfile;
}
if (opts.stream !== undefined) {
body["stream"] = opts.stream;
}
return body;
}
function isRetryableStatus(status: number): boolean {
return RETRYABLE_STATUS.has(status);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function problemFromResponse(response: Response): Promise<ReturnType<typeof problemFromJson>> {
try {
return problemFromJson(await response.json());
} catch {
return problemFromJson({
type: "about:blank",
title: "Error",
status: response.status,
code: "NON_PROBLEM_RESPONSE",
trace_id: "",
});
}
}
async function raiseForProblem(response: Response): Promise<void> {
if (response.status < 400) {
return;
}
throw new RAGCoreAPIError(await problemFromResponse(response));
}
async function* parseSseLines(lines: AsyncIterable<string>): AsyncGenerator<AnswerStreamEvent> {
let eventType: string | null = null;
let dataLines: string[] = [];
for await (const line of lines) {
if (line === "") {
if (eventType !== null) {
const data = dataLines.length > 0 ? (JSON.parse(dataLines.join("")) as Record<string, unknown>) : {};
yield { type: eventType as AnswerStreamEventType, data };
}
eventType = null;
dataLines = [];
continue;
}
if (line.startsWith("event:")) {
eventType = line.slice("event:".length).trim();
} else if (line.startsWith("data:")) {
dataLines.push(line.slice("data:".length).trim());
}
}
if (eventType !== null && dataLines.length > 0) {
const data = JSON.parse(dataLines.join("")) as Record<string, unknown>;
yield { type: eventType as AnswerStreamEventType, data };
}
}
/** Splits a byte stream into text lines, the way `iter_lines`/`aiter_lines` do. */
async function* streamLines(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
const reader = body.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let newlineIndex: number;
while ((newlineIndex = buffer.indexOf("\n")) !== -1) {
yield buffer.slice(0, newlineIndex).replace(/\r$/, "");
buffer = buffer.slice(newlineIndex + 1);
}
}
if (buffer.length > 0) {
yield buffer;
}
} finally {
reader.releaseLock();
}
}
export class RAGCoreClient {
readonly #baseUrl: string;
readonly #token: string;
readonly #fetchImpl: typeof fetch;
readonly #timeoutMs: number;
readonly #maxRetries: number;
constructor(baseUrl: string, token: string, options: RAGCoreClientOptions = {}) {
this.#baseUrl = baseUrl.replace(/\/+$/, "");
this.#token = token;
this.#fetchImpl = options.fetchImpl ?? fetch;
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.#maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
}
toString(): string {
return `RAGCoreClient(baseUrl=${this.#baseUrl})`;
}
async #post(path: string, body: Record<string, unknown>): Promise<Response> {
let attempt = 0;
for (;;) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.#timeoutMs);
try {
let response: Response;
try {
response = await this.#fetchImpl(`${this.#baseUrl}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.#token}`,
"x-ragcore-sdk-version": _SDK_VERSION,
},
body: JSON.stringify(body),
signal: controller.signal,
});
} catch (error) {
if (attempt >= this.#maxRetries) {
throw new RAGCoreConnectionError(error);
}
await sleep(BACKOFF_BASE_MS * 2 ** attempt);
attempt += 1;
continue;
}
if (!isRetryableStatus(response.status) || attempt >= this.#maxRetries) {
return response;
}
await sleep(BACKOFF_BASE_MS * 2 ** attempt);
attempt += 1;
} finally {
clearTimeout(timer);
}
}
}
async search(query: string, opts: QueryOptions = {}): Promise<SearchResponse> {
const response = await this.#post("/v1/search", searchBody(query, opts));
await raiseForProblem(response);
return (await response.json()) as SearchResponse;
}
async context(query: string, opts: ContextOptions = {}): Promise<ContextResponse> {
const response = await this.#post("/v1/context", searchBody(query, opts));
await raiseForProblem(response);
return (await response.json()) as ContextResponse;
}
async answer(query: string, opts: AnswerOptions = {}): Promise<AnswerResponse> {
const response = await this.#post("/v1/answers", searchBody(query, { ...opts, stream: false }));
await raiseForProblem(response);
return (await response.json()) as AnswerResponse;
}
async *answerStream(query: string, opts: AnswerOptions = {}): AsyncGenerator<AnswerStreamEvent> {
let response: Response;
try {
response = await this.#fetchImpl(`${this.#baseUrl}/v1/answers`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${this.#token}`,
"x-ragcore-sdk-version": _SDK_VERSION,
},
body: JSON.stringify(searchBody(query, { ...opts, stream: true })),
});
} catch (error) {
throw new RAGCoreConnectionError(error);
}
if (response.status >= 400) {
await raiseForProblem(response);
}
if (response.body === null) {
return;
}
yield* parseSseLines(streamLines(response.body));
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Error types raised by the RAGCore SDK.
*
* Every non-2xx response RAGCore returns is an RFC7807
* `application/problem+json` body shaped like
* `ragcore.api.v1.control.schemas.ProblemDetails`. The SDK parses that body
* once, here, rather than leaving every call site to re-parse raw JSON.
*/
export interface ProblemDetails {
type: string;
title: string;
status: number;
code: string;
detail: string | null;
trace_id: string;
errors: Array<Record<string, string>>;
}
export function problemFromJson(body: unknown): ProblemDetails {
const record = typeof body === "object" && body !== null ? (body as Record<string, unknown>) : {};
return {
type: typeof record["type"] === "string" ? record["type"] : "about:blank",
title: typeof record["title"] === "string" ? record["title"] : "Error",
status: typeof record["status"] === "number" ? record["status"] : 0,
code: typeof record["code"] === "string" ? record["code"] : "UNKNOWN",
detail: typeof record["detail"] === "string" ? record["detail"] : null,
trace_id: typeof record["trace_id"] === "string" ? record["trace_id"] : "",
errors: Array.isArray(record["errors"]) ? (record["errors"] as Array<Record<string, string>>) : [],
};
}
/** Base class for every error this SDK raises. */
export class RAGCoreError extends Error {}
/**
* A request never got a response, even after exhausting retries. Never
* carries request headers or the auth token in its message.
*/
export class RAGCoreConnectionError extends RAGCoreError {
constructor(cause: unknown) {
super(`connection failed: ${cause instanceof Error ? cause.message : String(cause)}`);
this.name = "RAGCoreConnectionError";
}
}
/** RAGCore answered with a non-2xx `application/problem+json` response. */
export class RAGCoreAPIError extends RAGCoreError {
readonly problem: ProblemDetails;
constructor(problem: ProblemDetails) {
super(`${problem.status} ${problem.code}: ${problem.detail ?? problem.title}`);
this.name = "RAGCoreAPIError";
this.problem = problem;
}
}
+20
View File
@@ -0,0 +1,20 @@
export { RAGCoreClient } from "./client.js";
export type { RAGCoreClientOptions } from "./client.js";
export { RAGCoreAPIError, RAGCoreConnectionError, RAGCoreError } from "./errors.js";
export type { ProblemDetails } from "./errors.js";
export { asDoneAnswer } from "./models.js";
export type {
AnswerClaim,
AnswerResponse,
AnswerStreamEvent,
AnswerStreamEventType,
AnswerabilityStatus,
Citation,
ContextBlock,
ContextResponse,
SearchFilter,
SearchResponse,
SearchResult,
SearchScores,
TrustLevel,
} from "./models.js";
+132
View File
@@ -0,0 +1,132 @@
/**
* Typed request/response models mirroring RAGCore's real
* `/v1/{search,context,answers}` Pydantic schemas
* (`ragcore.api.v1.search.schemas`, `.context.schemas`, `.answers.schemas`).
*
* Kept in lockstep with the server by hand (this SDK is handwritten, not
* generated) -- field names and shapes here must match those modules
* exactly. UUIDs and timestamps are plain `string` (the JSON wire shape),
* not wrapped types, to keep this dependency-free and browser-safe.
*/
export type TrustLevel =
| "trusted_internal"
| "trusted_external"
| "unverified"
| "user_uploaded"
| "internet";
export type AnswerabilityStatus =
| "answerable"
| "partially_answerable"
| "not_answerable"
| "conflicting_evidence";
export interface SearchFilter {
content_types?: string[];
languages?: string[];
source_ids?: string[];
document_ids?: string[];
trust_levels?: TrustLevel[];
}
export interface Citation {
id: string;
document_id: string;
document_version_id: string;
title: string;
section: string | null;
page_start: number | null;
page_end: number | null;
line_start: number | null;
line_end: number | null;
excerpt: string;
excerpt_sha256: string;
source_uri: string;
/** Provenance presentation (RAGCore >= 1.2.0); absent on older servers. */
source_id?: string | null;
source_name?: string | null;
source_kind?: "upload" | "folder" | "git" | "website" | null;
locator?: string | null;
revision?: string | null;
display?: string;
}
export interface SearchScores {
dense: number | null;
sparse: number | null;
fused: number | null;
rerank: number | null;
}
export interface SearchResult {
chunk_id: string;
parent_chunk_id: string | null;
text: string;
rank: number;
scores: SearchScores;
citation: Citation;
}
export interface SearchResponse {
retrieval_run_id: string;
effective_space_ids: string[];
results: SearchResult[];
degraded: boolean;
degradation_codes: string[];
timings_ms: Record<string, number>;
}
export interface ContextBlock {
text: string;
citation_ids: string[];
token_count: number;
}
export interface ContextResponse {
retrieval_run_id: string;
answerability: AnswerabilityStatus;
answerability_reasons: string[];
blocks: ContextBlock[];
citations: Citation[];
token_count: number;
degraded: boolean;
degradation_reasons: string[];
}
export interface AnswerClaim {
text: string;
citation_ids: string[];
}
export interface AnswerResponse {
answer_id: string;
retrieval_run_id: string;
answerability: AnswerabilityStatus;
answer: string;
citations: Citation[];
claims: AnswerClaim[];
degraded: boolean;
degradation_reasons: string[];
}
export type AnswerStreamEventType = "retrieval_metadata" | "done" | "error";
/**
* One parsed SSE frame from a streaming `/v1/answers` call. `data` is the
* raw decoded JSON payload -- its shape depends on `type` (see
* `ragcore.api.v1.answers.service._metadata_event/_done_event/_error_event`
* for the three real shapes). Use `asDoneAnswer()` for a typed view of a
* `done` event rather than re-parsing `data` by hand.
*/
export interface AnswerStreamEvent {
type: AnswerStreamEventType;
data: Record<string, unknown>;
}
export function asDoneAnswer(event: AnswerStreamEvent): AnswerResponse | null {
if (event.type !== "done") {
return null;
}
return event.data as unknown as AnswerResponse;
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"esModuleInterop": true
},
"include": ["src/**/*.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["../../tests/contract/sdk/typescript/**/*.test.ts"],
environment: "node",
},
});