Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,8 @@ export const api = {
method: "POST",
body: JSON.stringify({ query, cadence }),
}),
getNewsSubscription: (id: string) =>
request<NewsSubscription>(`/api/v1/news/subscriptions/${id}`),
deleteNewsSubscription: (id: string) =>
request<{ deleted: boolean; id: string }>(
`/api/v1/news/subscriptions/${id}`,
Expand Down Expand Up @@ -446,12 +448,12 @@ export const api = {
request<{ deleted: boolean; id: string }>(`/api/v1/documents/${id}`, {
method: "DELETE",
}),
searchDocuments: (query: string) =>
searchDocuments: (query: string, maxResults = 5) =>
request<{ results: Array<Record<string, unknown>> }>(
"/api/v1/documents/search",
{
method: "POST",
body: JSON.stringify({ query }),
body: JSON.stringify({ query, max_results: maxResults }),
},
),
};
Expand Down
7 changes: 6 additions & 1 deletion docs/feature-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ See also [parity-audit.md](parity-audit.md).
| LLM provider abstraction + think-tag handling | ✅ | 11 providers |
| Research history + export md/html/pdf | ✅ | API + web buttons |
| Docker Compose self-host | ✅ | `docker-compose.yml` |
| Python SDK | ✅ | `packages/sdk` |
| Python SDK | ✅ | `packages/sdk` — full REST mirror incl. upload, export download, news GET |
| Document library + RAG (`collection` engine) | ✅ | documents API + `document_index` |
| Provider settings persistence | ✅ | `/api/v1/settings` + Settings UI; resolvers prefer workspace overlay then env; GET responses redact secrets |
| MCP server exposing Synthora tools | ✅ | `/api/v1/mcp/tools/*` REST + `/mcp` streamable HTTP; optional ``config`` on ``start_research`` |
Expand Down Expand Up @@ -120,6 +120,11 @@ tools; env-driven MCP DNS rebinding protection
``max_concurrent_research_units``, ``max_researcher_iterations``, and
``max_react_tool_calls``; SDK/MCP/isolation regression tests.

Closed on ``feat/sdk-api-completeness``: SDK ``download_export`` (authenticated
bytes), ``get_news_subscription``, ``search_documents(max_results=...)``,
``health``/``ready``; web client ``getNewsSubscription`` and search
``max_results``; streamable MCP workspace isolation test.

No known functional gaps remain beyond explicit non-goals below.

Chat remains session-scoped ``fast_research`` with prior-report memory —
Expand Down
30 changes: 26 additions & 4 deletions packages/sdk/src/synthora/sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,16 @@ def get_discourse(self, run_id: str) -> list[dict]:
def export_url(self, run_id: str, fmt: str = "markdown") -> str:
return f"{self.base_url}/api/v1/research/{run_id}/export?format={fmt}"

def download_export(self, run_id: str, fmt: str = "markdown") -> bytes:
"""Download export bytes with auth (session mode safe)."""
resp = self._client.get(
f"/api/v1/research/{run_id}/export",
params={"format": fmt},
headers=self._headers(),
)
resp.raise_for_status()
return resp.content

def list_pipelines(self) -> list[dict]:
return self._get("/api/v1/pipelines")["pipelines"]

Expand Down Expand Up @@ -199,16 +209,20 @@ def upload_document(
def delete_document(self, document_id: str) -> dict:
return self._delete(f"/api/v1/documents/{document_id}")

def search_documents(self, query: str) -> list[dict]:
return self._post("/api/v1/documents/search", {"query": query}).get(
"results", []
)
def search_documents(self, query: str, *, max_results: int = 5) -> list[dict]:
return self._post(
"/api/v1/documents/search",
{"query": query, "max_results": max_results},
).get("results", [])

# -- news ----------------------------------------------------------------

def list_news_subscriptions(self) -> list[dict]:
return self._get("/api/v1/news/subscriptions")["subscriptions"]

def get_news_subscription(self, subscription_id: str) -> dict:
return self._get(f"/api/v1/news/subscriptions/{subscription_id}")

def create_news_subscription(self, query: str, *, cadence: str = "daily") -> dict:
return self._post(
"/api/v1/news/subscriptions", {"query": query, "cadence": cadence}
Expand Down Expand Up @@ -262,6 +276,14 @@ def mcp_tools_call(self, name: str, arguments: Optional[dict] = None) -> dict:
{"name": name, "arguments": arguments or {}},
)

# -- ops -----------------------------------------------------------------

def health(self) -> dict:
return self._get("/health")

def ready(self) -> dict:
return self._get("/ready")

# -- plumbing ----------------------------------------------------------

def _headers(self) -> dict:
Expand Down
51 changes: 51 additions & 0 deletions tests/test_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,57 @@ def test_session_auth_workspace_and_ws_isolation(platform):
)
assert alice_mcp.status_code == 200
assert json.loads(alice_mcp.json()["content"])["run_id"] == run_id

# Streamable MCP must also reject cross-workspace status reads.
stream_headers = {
**alice_h,
"Accept": "application/json, text/event-stream",
}
init = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "iso-test", "version": "0.1"},
},
},
headers=stream_headers,
)
assert init.status_code == 200
session_id = init.headers.get("mcp-session-id")
bob_stream_h = {
**bob_h,
"Accept": "application/json, text/event-stream",
}
if session_id:
bob_stream_h["Mcp-Session-Id"] = session_id
bob_stream = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "get_run_status",
"arguments": {"run_id": run_id},
},
},
headers=bob_stream_h,
)
assert bob_stream.status_code == 200
bob_text = next(
(
b["text"]
for b in bob_stream.json()["result"]["content"]
if b.get("type") == "text"
),
"",
)
assert "run not found" in json.loads(bob_text)["error"]
finally:
settings.auth_mode = "none"
settings.secret_key = "change-me"
Expand Down
54 changes: 51 additions & 3 deletions tests/test_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pytest
from synthora.sdk.client import SynthoraClient

from tests.test_platform import fake_run_config
from tests.test_platform import fake_run_config, make_executor

pytest_plugins = ("tests.test_platform",)

Expand All @@ -18,6 +18,10 @@ def __init__(self, response) -> None:
self._response = response
self.status_code = response.status_code

@property
def content(self) -> bytes:
return self._response.content

def raise_for_status(self) -> None:
if self.status_code >= 400:
self._response.raise_for_status()
Expand All @@ -32,8 +36,16 @@ class _TestHttpClient:
def __init__(self, test_client) -> None:
self._client = test_client

def get(self, path: str, *, headers: Optional[dict] = None) -> _TestResponse:
return _TestResponse(self._client.get(path, headers=headers or {}))
def get(
self,
path: str,
*,
headers: Optional[dict] = None,
params: Optional[dict] = None,
) -> _TestResponse:
return _TestResponse(
self._client.get(path, headers=headers or {}, params=params or {})
)

def post(
self,
Expand Down Expand Up @@ -121,3 +133,39 @@ def test_sdk_mcp_tools_call(sdk):
payload = json.loads(started["content"])
assert payload["run_id"]
assert payload["status"] == "queued"


def test_sdk_get_news_subscription(sdk):
sub = sdk.create_news_subscription("climate tech", cadence="daily")
fetched = sdk.get_news_subscription(sub["id"])
assert fetched["id"] == sub["id"]
assert fetched["query"] == "climate tech"


def test_sdk_search_documents_max_results(sdk):
sdk.create_document("Alpha", "alpha unique token one two three")
sdk.create_document("Beta", "beta unique token four five six")
hits = sdk.search_documents("unique token", max_results=1)
assert len(hits) == 1


def test_sdk_download_export(platform, sdk):
client, app = platform
run_id = client.post(
"/api/v1/research",
json={
"question": "Export via SDK?",
"pipeline_id": "fast_research",
"config": fake_run_config(),
},
).json()["run_id"]
client.portal.call(make_executor(app).execute, run_id)
markdown = sdk.download_export(run_id, "markdown")
assert b"Integration Report" in markdown
html = sdk.download_export(run_id, "html")
assert b"<" in html


def test_sdk_health_and_ready(sdk):
assert sdk.health()["status"] == "ok"
assert sdk.ready()["status"] == "ready"
Loading