From f73d92d8e3d163351bdb49de0097f01587ad289b Mon Sep 17 00:00:00 2001 From: ehz0ah Date: Tue, 4 Aug 2026 01:22:46 +0800 Subject: [PATCH] fix(sdk): expose retrieval controls consistently Add time, level, and provenance options to the standalone Python SDK and expose provenance in the TypeScript SDK while preserving existing defaults. --- sdk/python/openviking_sdk/client.py | 40 ++++++++ .../tests/test_async_client_behaviors.py | 92 ++++++++++++++++++- sdk/typescript/src/client.ts | 1 + sdk/typescript/src/types.ts | 1 + sdk/typescript/tests/client.test.ts | 25 ++++- 5 files changed, 151 insertions(+), 8 deletions(-) diff --git a/sdk/python/openviking_sdk/client.py b/sdk/python/openviking_sdk/client.py index 77db3ddd5c..8846e9d33f 100644 --- a/sdk/python/openviking_sdk/client.py +++ b/sdk/python/openviking_sdk/client.py @@ -1197,6 +1197,11 @@ async def find( tags: Optional[List[str]] = None, telemetry: Any = False, image: Any = None, + since: Optional[str] = None, + until: Optional[str] = None, + time_field: Optional[str] = None, + level: Optional[List[int]] = None, + include_provenance: Optional[bool] = None, ) -> Dict[str, Any]: actual_limit = node_limit if node_limit is not None else limit payload = { @@ -1208,6 +1213,11 @@ async def find( "filter": filter, "context_type": self._normalize_context_type(context_type), "tags": tags, + "since": since, + "until": until, + "time_field": time_field, + "level": level, + "include_provenance": include_provenance, "telemetry": telemetry, } payload = self._compact_request_body(payload) @@ -1228,6 +1238,11 @@ async def search( tags: Optional[List[str]] = None, telemetry: Any = False, image: Any = None, + since: Optional[str] = None, + until: Optional[str] = None, + time_field: Optional[str] = None, + level: Optional[List[int]] = None, + include_provenance: Optional[bool] = None, ) -> Dict[str, Any]: actual_limit = node_limit if node_limit is not None else limit sid = session_id or (session.session_id if session else None) @@ -1241,6 +1256,11 @@ async def search( "filter": filter, "context_type": self._normalize_context_type(context_type), "tags": tags, + "since": since, + "until": until, + "time_field": time_field, + "level": level, + "include_provenance": include_provenance, "telemetry": telemetry, } payload = self._compact_request_body(payload) @@ -2220,6 +2240,11 @@ def find( tags: Optional[List[str]] = None, telemetry: Any = False, image: Any = None, + since: Optional[str] = None, + until: Optional[str] = None, + time_field: Optional[str] = None, + level: Optional[List[int]] = None, + include_provenance: Optional[bool] = None, ) -> Dict[str, Any]: return run_async( self._async_client.find( @@ -2233,6 +2258,11 @@ def find( tags=tags, telemetry=telemetry, image=image, + since=since, + until=until, + time_field=time_field, + level=level, + include_provenance=include_provenance, ) ) @@ -2250,6 +2280,11 @@ def search( tags: Optional[List[str]] = None, telemetry: Any = False, image: Any = None, + since: Optional[str] = None, + until: Optional[str] = None, + time_field: Optional[str] = None, + level: Optional[List[int]] = None, + include_provenance: Optional[bool] = None, ) -> Dict[str, Any]: actual_session_id = session_id if actual_session_id is None and session is not None: @@ -2267,6 +2302,11 @@ def search( tags=tags, telemetry=telemetry, image=image, + since=since, + until=until, + time_field=time_field, + level=level, + include_provenance=include_provenance, ) ) diff --git a/sdk/python/tests/test_async_client_behaviors.py b/sdk/python/tests/test_async_client_behaviors.py index bc374e48ab..6e53d9dca4 100644 --- a/sdk/python/tests/test_async_client_behaviors.py +++ b/sdk/python/tests/test_async_client_behaviors.py @@ -16,6 +16,47 @@ def test_add_resource_signatures_keep_telemetry_position(): assert params.index("telemetry") < params.index("tag_mode") +@pytest.mark.parametrize( + ("method_name", "expected_extra"), + [ + ("find", {}), + ("search", {"session_id": None}), + ], +) +def test_sync_http_client_forwards_retrieval_contract_fields(method_name, expected_extra): + client = SyncHTTPClient(url="http://localhost:1933") + retrieval_options = { + "since": "2026-08-01T00:00:00Z", + "until": "2026-08-02T00:00:00Z", + "time_field": "updated_at", + "level": [1, 2], + "include_provenance": True, + } + + with patch.object( + client._async_client, + method_name, + return_value={"total": 0, "resources": []}, + ) as mock_retrieval: + result = getattr(client, method_name)(query="sample", **retrieval_options) + + assert result == {"total": 0, "resources": []} + mock_retrieval.assert_called_once_with( + query="sample", + target_uri="", + limit=10, + node_limit=None, + score_threshold=None, + filter=None, + context_type=None, + tags=None, + telemetry=False, + image=None, + **retrieval_options, + **expected_extra, + ) + + @pytest.mark.asyncio async def test_async_http_client_initialize_forwards_event_hooks(): async def request_hook(_request): @@ -728,7 +769,7 @@ async def test_add_resource_sends_tags_and_tag_mode(): @pytest.mark.asyncio -async def test_find_uses_node_limit_as_http_limit_and_normalizes_target_uri_list(): +async def test_find_sends_exact_retrieval_body_when_optional_fields_are_set(): client = AsyncHTTPClient(url="http://localhost:1933") fake_http = SimpleNamespace(post=AsyncMock(return_value=object())) client._http = fake_http @@ -744,6 +785,11 @@ async def test_find_uses_node_limit_as_http_limit_and_normalizes_target_uri_list context_type="resource", tags=["k:v"], telemetry={"enabled": True}, + since="2026-08-01T00:00:00Z", + until="2026-08-02T00:00:00Z", + time_field="created_at", + level=[0, 2], + include_provenance=True, ) fake_http.post.assert_awaited_once_with( @@ -756,20 +802,35 @@ async def test_find_uses_node_limit_as_http_limit_and_normalizes_target_uri_list "filter": {"type": "resource"}, "context_type": "resource", "tags": ["k:v"], + "since": "2026-08-01T00:00:00Z", + "until": "2026-08-02T00:00:00Z", + "time_field": "created_at", + "level": [0, 2], + "include_provenance": True, "telemetry": {"enabled": True}, }, ) @pytest.mark.asyncio -async def test_search_uses_session_wrapper_session_id_in_payload(): +async def test_search_sends_exact_retrieval_body_with_session_context(): client = AsyncHTTPClient(url="http://localhost:1933") fake_http = SimpleNamespace(post=AsyncMock(return_value=object())) client._http = fake_http client._handle_response_data = lambda _response: {"result": {"total": 0, "resources": []}} session = Session(client, "thread-123") - await client.search(query="sample", target_uri="/resources/demo", session=session, limit=5) + await client.search( + query="sample", + target_uri="/resources/demo", + session=session, + limit=5, + since="7d", + until="now", + time_field="updated_at", + level=[1], + include_provenance=False, + ) fake_http.post.assert_awaited_once_with( "/api/v1/search/search", @@ -778,6 +839,31 @@ async def test_search_uses_session_wrapper_session_id_in_payload(): "target_uri": "viking://resources/demo", "session_id": "thread-123", "limit": 5, + "since": "7d", + "until": "now", + "time_field": "updated_at", + "level": [1], + "include_provenance": False, + "telemetry": False, + }, + ) + + +@pytest.mark.asyncio +async def test_find_omits_unset_retrieval_fields_from_exact_body(): + client = AsyncHTTPClient(url="http://localhost:1933") + fake_http = SimpleNamespace(post=AsyncMock(return_value=object())) + client._http = fake_http + client._handle_response_data = lambda _response: {"result": {"total": 0, "resources": []}} + + await client.find(query="sample") + + fake_http.post.assert_awaited_once_with( + "/api/v1/search/find", + json={ + "query": "sample", + "target_uri": "", + "limit": 10, "telemetry": False, }, ) diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 10f43d3260..78a306a85a 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -349,6 +349,7 @@ export class OpenVikingClient { until: options.until, time_field: options.timeField, level: options.level, + include_provenance: options.includeProvenance, tags: options.tags, }), }); diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index ea9ecf8a77..9aaa381b16 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -105,6 +105,7 @@ export interface SearchOptions { until?: string; timeField?: string; level?: number[]; + includeProvenance?: boolean; tags?: string[]; } /** Content grep options. */ diff --git a/sdk/typescript/tests/client.test.ts b/sdk/typescript/tests/client.test.ts index ca8d4ca39c..2f72a67788 100644 --- a/sdk/typescript/tests/client.test.ts +++ b/sdk/typescript/tests/client.test.ts @@ -22,7 +22,7 @@ describe("OpenVikingClient", () => { ); }); - it("sends identity headers and the Python/Go compatible search body", async () => { + it("sends the exact retrieval body when optional fields are set", async () => { const fetcher = vi .fn() .mockResolvedValue(ok({ resources: [] })); @@ -34,20 +34,33 @@ describe("OpenVikingClient", () => { actorPeerId: "peer", fetch: fetcher, }); - await client.find("hello", { targetUri: "viking://resources", limit: 5 }); + await client.find("hello", { + targetUri: "viking://resources", + limit: 5, + since: "2026-08-01T00:00:00Z", + until: "2026-08-02T00:00:00Z", + timeField: "created_at", + level: [0, 2], + includeProvenance: true, + }); const [url, init] = fetcher.mock.calls[0]!; expect(String(url)).toBe("https://example.com/api/v1/search/find"); expect(new Headers(init?.headers).get("X-OpenViking-Actor-Peer")).toBe( "peer", ); - expect(JSON.parse(String(init?.body))).toMatchObject({ + expect(JSON.parse(String(init?.body))).toEqual({ query: "hello", target_uri: "viking://resources", limit: 5, + since: "2026-08-01T00:00:00Z", + until: "2026-08-02T00:00:00Z", + time_field: "created_at", + level: [0, 2], + include_provenance: true, }); }); - it("uses the Python/Go empty default retrieval target", async () => { + it("omits unset retrieval options from the exact body", async () => { const fetcher = vi .fn() .mockResolvedValue(ok({ resources: [] })); @@ -58,8 +71,10 @@ describe("OpenVikingClient", () => { await client.search("hello"); - expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toMatchObject({ + expect(JSON.parse(String(fetcher.mock.calls[0]![1]?.body))).toEqual({ + query: "hello", target_uri: "", + limit: 10, }); });