Skip to content

Commit db2f047

Browse files
committed
feat: include_sources parameter
1 parent 73673bb commit db2f047

File tree

6 files changed

+187
-26
lines changed

6 files changed

+187
-26
lines changed

examples/2_sourced_answer_search.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,6 @@
2020
query="What are the 3 major events in the life of Abraham Lincoln ?",
2121
depth="standard", # or "deep"
2222
output_type="sourcedAnswer",
23+
include_inline_citations=False,
2324
)
2425
print(response)

examples/3_structured_search.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class Events(BaseModel):
3131
query="What are the 3 major events in the life of Abraham Lincoln?",
3232
depth="standard", # or "deep"
3333
output_type="structured",
34-
structured_output_schema=Events,
34+
structured_output_schema=Events, # or json.dumps(Events.model_json_schema())
35+
include_sources=False,
3536
)
3637
print(response)

src/linkup/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
LinkupFetchResponse,
1414
LinkupSearchImageResult,
1515
LinkupSearchResults,
16+
LinkupSearchStructuredResponse,
1617
LinkupSearchTextResult,
1718
LinkupSource,
1819
LinkupSourcedAnswer,
@@ -31,6 +32,7 @@
3132
"LinkupFetchResponse",
3233
"LinkupSearchImageResult",
3334
"LinkupSearchResults",
35+
"LinkupSearchStructuredResponse",
3436
"LinkupSearchTextResult",
3537
"LinkupSource",
3638
"LinkupSourcedAnswer",

src/linkup/client.py

Lines changed: 59 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616
LinkupTooManyRequestsError,
1717
LinkupUnknownError,
1818
)
19-
from linkup.types import LinkupFetchResponse, LinkupSearchResults, LinkupSourcedAnswer
19+
from linkup.types import (
20+
LinkupFetchResponse,
21+
LinkupSearchResults,
22+
LinkupSearchStructuredResponse,
23+
LinkupSourcedAnswer,
24+
)
2025

2126

2227
class LinkupClient:
@@ -58,6 +63,7 @@ def search(
5863
exclude_domains: Optional[list[str]] = None,
5964
include_domains: Optional[list[str]] = None,
6065
include_inline_citations: Optional[bool] = None,
66+
include_sources: Optional[bool] = None,
6167
) -> Any:
6268
"""Perform a web search using the Linkup API `search` endpoint.
6369
@@ -85,13 +91,18 @@ def search(
8591
include_domains: If you want the search to only return results from certain domains.
8692
include_inline_citations: If output_type is "sourcedAnswer", indicate whether the
8793
answer should include inline citations.
94+
include_sources: If output_type is "structured", indicate whether the answer should
95+
include sources. This will modify the schema of the structured response.
8896
8997
Returns:
90-
The Linkup API search result. If output_type is "searchResults", the result will be a
91-
linkup.LinkupSearchResults. If output_type is "sourcedAnswer", the result will be a
92-
linkup.LinkupSourcedAnswer. If output_type is "structured", the result will be
93-
either an instance of the provided pydantic.BaseModel, or an arbitrary data
94-
structure, following structured_output_schema.
98+
The Linkup API search result, which can have different types based on the parameters:
99+
- LinkupSearchResults if output_type is "searchResults"
100+
- LinkupSourcedAnswer if output_type is "sourcedAnswer"
101+
- the provided pydantic.BaseModel or an arbitrary data structure if output_type is
102+
"structured" and include_sources is False
103+
- LinkupSearchStructuredResponse with the provided pydantic.BaseModel or an arbitrary
104+
data structure as data field, if output_type is "structured" and include_sources is
105+
True
95106
96107
Raises:
97108
TypeError: If structured_output_schema is not provided or is not a string or a
@@ -113,6 +124,7 @@ def search(
113124
exclude_domains=exclude_domains,
114125
include_domains=include_domains,
115126
include_inline_citations=include_inline_citations,
127+
include_sources=include_sources,
116128
)
117129

118130
response: httpx.Response = self._request(
@@ -128,6 +140,7 @@ def search(
128140
response=response,
129141
output_type=output_type,
130142
structured_output_schema=structured_output_schema,
143+
include_sources=include_sources,
131144
)
132145

133146
async def async_search(
@@ -142,6 +155,7 @@ async def async_search(
142155
exclude_domains: Optional[list[str]] = None,
143156
include_domains: Optional[list[str]] = None,
144157
include_inline_citations: Optional[bool] = None,
158+
include_sources: Optional[bool] = None,
145159
) -> Any:
146160
"""Asynchronously perform a web search using the Linkup API `search` endpoint.
147161
@@ -169,13 +183,18 @@ async def async_search(
169183
include_domains: If you want the search to only return results from certain domains.
170184
include_inline_citations: If output_type is "sourcedAnswer", indicate whether the
171185
answer should include inline citations.
186+
include_sources: If output_type is "structured", indicate whether the answer should
187+
include sources. This will modify the schema of the structured response.
172188
173189
Returns:
174-
The Linkup API search result. If output_type is "searchResults", the result will be a
175-
linkup.LinkupSearchResults. If output_type is "sourcedAnswer", the result will be a
176-
linkup.LinkupSourcedAnswer. If output_type is "structured", the result will be
177-
either an instance of the provided pydantic.BaseModel, or an arbitrary data
178-
structure, following structured_output_schema.
190+
The Linkup API search result, which can have different types based on the parameters:
191+
- LinkupSearchResults if output_type is "searchResults"
192+
- LinkupSourcedAnswer if output_type is "sourcedAnswer"
193+
- the provided pydantic.BaseModel or an arbitrary data structure if output_type is
194+
"structured" and include_sources is False
195+
- LinkupSearchStructuredResponse with the provided pydantic.BaseModel or an arbitrary
196+
data structure as data field, if output_type is "structured" and include_sources is
197+
True
179198
180199
Raises:
181200
TypeError: If structured_output_schema is not provided or is not a string or a
@@ -197,6 +216,7 @@ async def async_search(
197216
exclude_domains=exclude_domains,
198217
include_domains=include_domains,
199218
include_inline_citations=include_inline_citations,
219+
include_sources=include_sources,
200220
)
201221

202222
response: httpx.Response = await self._async_request(
@@ -212,6 +232,7 @@ async def async_search(
212232
response=response,
213233
output_type=output_type,
214234
structured_output_schema=structured_output_schema,
235+
include_sources=include_sources,
215236
)
216237

217238
def fetch(
@@ -419,6 +440,7 @@ def _get_search_params(
419440
exclude_domains: Optional[list[str]],
420441
include_domains: Optional[list[str]],
421442
include_inline_citations: Optional[bool],
443+
include_sources: Optional[bool],
422444
) -> dict[str, Union[str, bool, list[str]]]:
423445
params: dict[str, Union[str, bool, list[str]]] = dict(
424446
q=query,
@@ -448,6 +470,8 @@ def _get_search_params(
448470
params["includeDomains"] = include_domains
449471
if include_inline_citations is not None:
450472
params["includeInlineCitations"] = include_inline_citations
473+
if include_sources is not None:
474+
params["includeSources"] = include_sources
451475

452476
return params
453477

@@ -471,23 +495,35 @@ def _parse_search_response(
471495
response: httpx.Response,
472496
output_type: Literal["searchResults", "sourcedAnswer", "structured"],
473497
structured_output_schema: Union[type[BaseModel], str, None],
498+
include_sources: Optional[bool],
474499
) -> Any:
475500
response_data: Any = response.json()
476-
output_base_model: Optional[type[BaseModel]] = None
477501
if output_type == "searchResults":
478-
output_base_model = LinkupSearchResults
502+
return LinkupSearchResults.model_validate(response_data)
479503
elif output_type == "sourcedAnswer":
480-
output_base_model = LinkupSourcedAnswer
481-
elif (
482-
output_type == "structured"
483-
and not isinstance(structured_output_schema, (str, type(None)))
484-
and issubclass(structured_output_schema, BaseModel)
485-
):
486-
output_base_model = structured_output_schema
487-
488-
if output_base_model is None:
504+
return LinkupSourcedAnswer.model_validate(response_data)
505+
elif output_type == "structured":
506+
if structured_output_schema is None:
507+
raise ValueError(
508+
"structured_output_schema must be provided when output_type is 'structured'"
509+
)
510+
# HACK: we assume that `include_sources` will default to False, since the API output can
511+
# be arbitrary so we can't guess if it includes sources or not
512+
if include_sources:
513+
if not isinstance(structured_output_schema, str) and issubclass(
514+
structured_output_schema, BaseModel
515+
):
516+
response_data["data"] = structured_output_schema.model_validate(
517+
response_data["data"]
518+
)
519+
return LinkupSearchStructuredResponse.model_validate(response_data)
520+
if not isinstance(structured_output_schema, str) and issubclass(
521+
structured_output_schema, BaseModel
522+
):
523+
return structured_output_schema.model_validate(response_data)
489524
return response_data
490-
return output_base_model.model_validate(response_data)
525+
else:
526+
raise ValueError(f"Unexpected output_type value: '{output_type}'")
491527

492528
def _parse_fetch_response(self, response: httpx.Response) -> LinkupFetchResponse:
493529
return LinkupFetchResponse.model_validate(response.json())

src/linkup/types.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Literal, Optional, Union
1+
from typing import Any, Literal, Optional, Union
22

33
from pydantic import BaseModel, ConfigDict, Field
44

@@ -69,6 +69,18 @@ class LinkupSourcedAnswer(BaseModel):
6969
sources: list[LinkupSource]
7070

7171

72+
class LinkupSearchStructuredResponse(BaseModel):
73+
"""A Linkup `search` structured response, with the sources supporting it.
74+
75+
Attributes:
76+
data: The answer data, either as a Pydantic model or an arbitrary JSON structure.
77+
sources: The sources supporting the answer.
78+
"""
79+
80+
data: Any
81+
sources: list[Union[LinkupSearchTextResult, LinkupSearchImageResult]]
82+
83+
7284
class LinkupFetchResponse(BaseModel):
7385
"""The response from a Linkup web page fetch.
7486

tests/unit/client_test.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,12 @@
2222
LinkupNoResultError,
2323
LinkupTooManyRequestsError,
2424
)
25-
from linkup.types import LinkupFetchResponse, LinkupSearchImageResult, LinkupSearchTextResult
25+
from linkup.types import (
26+
LinkupFetchResponse,
27+
LinkupSearchImageResult,
28+
LinkupSearchStructuredResponse,
29+
LinkupSearchTextResult,
30+
)
2631

2732

2833
class Company(BaseModel):
@@ -181,6 +186,110 @@ class Company(BaseModel):
181186
website_url="https://www.linkup.so/",
182187
),
183188
),
189+
(
190+
{
191+
"query": "query",
192+
"depth": "standard",
193+
"output_type": "structured",
194+
"structured_output_schema": Company,
195+
"include_sources": True,
196+
},
197+
{
198+
"q": "query",
199+
"depth": "standard",
200+
"outputType": "structured",
201+
"structuredOutputSchema": json.dumps(Company.model_json_schema()),
202+
"includeSources": True,
203+
},
204+
b"""
205+
{
206+
"data": {
207+
"name": "Linkup",
208+
"founders_names": ["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"],
209+
"creation_date": "2024",
210+
"website_url": "https://www.linkup.so/"
211+
},
212+
"sources": [
213+
{
214+
"type": "text",
215+
"name": "foo",
216+
"url": "https://foo.com",
217+
"content": "lorem ipsum dolor sit amet"
218+
},
219+
{"type": "image", "name": "bar", "url": "https://bar.com"}
220+
]
221+
}
222+
""",
223+
LinkupSearchStructuredResponse(
224+
data=Company(
225+
name="Linkup",
226+
founders_names=["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"],
227+
creation_date="2024",
228+
website_url="https://www.linkup.so/",
229+
),
230+
sources=[
231+
LinkupSearchTextResult(
232+
type="text",
233+
name="foo",
234+
url="https://foo.com",
235+
content="lorem ipsum dolor sit amet",
236+
),
237+
LinkupSearchImageResult(type="image", name="bar", url="https://bar.com"),
238+
],
239+
),
240+
),
241+
(
242+
{
243+
"query": "query",
244+
"depth": "standard",
245+
"output_type": "structured",
246+
"structured_output_schema": json.dumps(Company.model_json_schema()),
247+
"include_sources": True,
248+
},
249+
{
250+
"q": "query",
251+
"depth": "standard",
252+
"outputType": "structured",
253+
"structuredOutputSchema": json.dumps(Company.model_json_schema()),
254+
"includeSources": True,
255+
},
256+
b"""
257+
{
258+
"data": {
259+
"name": "Linkup",
260+
"founders_names": ["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"],
261+
"creation_date": "2024",
262+
"website_url": "https://www.linkup.so/"
263+
},
264+
"sources": [
265+
{
266+
"type": "text",
267+
"name": "foo",
268+
"url": "https://foo.com",
269+
"content": "lorem ipsum dolor sit amet"
270+
},
271+
{"type": "image", "name": "bar", "url": "https://bar.com"}
272+
]
273+
}
274+
""",
275+
LinkupSearchStructuredResponse(
276+
data=dict(
277+
name="Linkup",
278+
founders_names=["Philippe Mizrahi", "Denis Charrier", "Boris Toledano"],
279+
creation_date="2024",
280+
website_url="https://www.linkup.so/",
281+
),
282+
sources=[
283+
LinkupSearchTextResult(
284+
type="text",
285+
name="foo",
286+
url="https://foo.com",
287+
content="lorem ipsum dolor sit amet",
288+
),
289+
LinkupSearchImageResult(type="image", name="bar", url="https://bar.com"),
290+
],
291+
),
292+
),
184293
]
185294

186295

0 commit comments

Comments
 (0)