Skip to content
Closed
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
44 changes: 39 additions & 5 deletions src/linkup/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,9 @@ def research(
timeout=timeout,
)

return self._parse_research_task(response.json())
return self._parse_research_task(
response.json(), structured_output_schema=structured_output_schema
)

async def async_research(
self,
Expand Down Expand Up @@ -427,7 +429,9 @@ async def async_research(
timeout=timeout,
)

return self._parse_research_task(response.json())
return self._parse_research_task(
response.json(), structured_output_schema=structured_output_schema
)

def list_research(
self,
Expand Down Expand Up @@ -1456,12 +1460,42 @@ def _parse_fetch_response(self, response: httpx.Response) -> LinkupFetchResponse
def _parse_fetch_response_data(self, response_data: Any) -> LinkupFetchResponse: # noqa: ANN401
return LinkupFetchResponse.model_validate(response_data)

def _parse_research_task(self, task_data: dict[str, Any]) -> LinkupResearchTask:
def _parse_research_output_data(
self,
response_data: Any, # noqa: ANN401
output_type: Literal["sourcedAnswer", "structured"],
structured_output_schema: type[BaseModel] | dict[str, Any] | str | None,
) -> Any: # noqa: ANN401
if output_type == "sourcedAnswer":
return LinkupSourcedAnswer.model_validate(response_data)
if output_type == "structured":
if structured_output_schema is None:
raise ValueError(
"structured_output_schema must be provided when output_type is 'structured'"
)
if isinstance(structured_output_schema, type) and issubclass(
structured_output_schema, BaseModel
):
return structured_output_schema.model_validate(response_data)
return response_data
raise ValueError(f"Unexpected output_type value: '{output_type}'")

def _parse_research_task(
self,
task_data: dict[str, Any],
structured_output_schema: type[BaseModel] | dict[str, Any] | str | None = None,
) -> LinkupResearchTask:
research_input = LinkupResearchTaskInput.model_validate(task_data["input"])
parsed_output = task_data.get("output")

if parsed_output is not None and research_input.output_type == "sourcedAnswer":
parsed_output = LinkupSourcedAnswer.model_validate(parsed_output)
if parsed_output is not None:
parsed_output = self._parse_research_output_data(
response_data=parsed_output,
output_type=research_input.output_type,
structured_output_schema=structured_output_schema
if structured_output_schema is not None
else research_input.structured_output_schema,
)

return LinkupResearchTask.model_validate(
{**task_data, "input": research_input, "output": parsed_output}
Expand Down
59 changes: 56 additions & 3 deletions tests/unit/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ class Company(BaseModel):
founders_names: list[str]


class ResearchSummary(BaseModel):
summary: str


test_search_parameters = [
(
{"query": "query", "depth": "standard", "output_type": "searchResults"},
Expand Down Expand Up @@ -638,6 +642,55 @@ def test_research(mocker: MockerFixture, client: linkup.Client) -> None:
)


def test_research_structured_output_model(mocker: MockerFixture, client: linkup.Client) -> None:
request_mock = mocker.patch(
"httpx.Client.request",
return_value=Response(
status_code=200,
content=b"""
{
"createdAt": "2026-05-18T00:00:00.000Z",
"error": null,
"id": "bfeb26f5-f4d6-47d2-9818-7f62fbcd0b0c",
"input": {
"outputType": "structured",
"q": "query",
"structuredOutputSchema": {
"type": "object"
}
},
"output": {
"summary": "done"
},
"status": "completed",
"type": "research",
"updatedAt": "2026-05-18T00:00:00.000Z"
}
""",
),
)

research_response = client.research(
query="query",
output_type="structured",
structured_output_schema=ResearchSummary,
)

request_mock.assert_called_once_with(
method="POST",
url="/research",
json={
"q": "query",
"outputType": "structured",
"structuredOutputSchema": json.dumps(ResearchSummary.model_json_schema()),
},
timeout=None,
)
assert research_response.output == ResearchSummary(summary="done")
assert research_response.input.query == "query"
assert research_response.input.structured_output_schema == {"type": "object"}


@pytest.mark.asyncio
async def test_async_research(mocker: MockerFixture, client: linkup.Client) -> None:
request_mock = mocker.patch(
Expand Down Expand Up @@ -670,7 +723,7 @@ async def test_async_research(mocker: MockerFixture, client: linkup.Client) -> N
research_response = await client.async_research(
query="query",
output_type="structured",
structured_output_schema=Company,
structured_output_schema=ResearchSummary,
)

request_mock.assert_called_once_with(
Expand All @@ -679,11 +732,11 @@ async def test_async_research(mocker: MockerFixture, client: linkup.Client) -> N
json={
"q": "query",
"outputType": "structured",
"structuredOutputSchema": json.dumps(Company.model_json_schema()),
"structuredOutputSchema": json.dumps(ResearchSummary.model_json_schema()),
},
timeout=None,
)
assert research_response.output == {"summary": "done"}
assert research_response.output == ResearchSummary(summary="done")
assert research_response.input.query == "query"
assert research_response.input.structured_output_schema == {"type": "object"}

Expand Down
Loading