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
4 changes: 4 additions & 0 deletions src/linkup/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
LinkupTaskMetadata,
LinkupTaskQuota,
LinkupTasksPage,
LinkupUnsupportedTask,
)
from ._version import __version__

Expand Down Expand Up @@ -78,6 +79,7 @@
TimeoutError = LinkupTimeoutError # noqa: A001
TooManyRequestsError = LinkupTooManyRequestsError
UnknownError = LinkupUnknownError
UnsupportedTask = LinkupUnsupportedTask

__all__ = [
"AuthenticationError",
Expand Down Expand Up @@ -130,6 +132,7 @@
"LinkupTimeoutError",
"LinkupTooManyRequestsError",
"LinkupUnknownError",
"LinkupUnsupportedTask",
"NoResultError",
"PaymentRequiredError",
"ResearchTask",
Expand All @@ -153,5 +156,6 @@
"TimeoutError",
"TooManyRequestsError",
"UnknownError",
"UnsupportedTask",
"__version__",
]
5 changes: 4 additions & 1 deletion src/linkup/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
LinkupTask,
LinkupTaskInput,
LinkupTasksPage,
LinkupUnsupportedTask,
)
from ._version import __version__

Expand Down Expand Up @@ -1692,7 +1693,9 @@ def _parse_task(self, task_data: dict[str, Any]) -> LinkupTask:
if task_type == "research":
return self._parse_research_task(task_data)

raise ValueError(f"Unexpected task type value: '{task_type}'")
return LinkupUnsupportedTask.model_validate(
{**task_data, "rawType": task_type, "type": "unsupported"}
)

def _parse_research_tasks_page(self, response_data: dict[str, Any]) -> LinkupResearchTasksPage:
return LinkupResearchTasksPage.model_validate(
Expand Down
28 changes: 27 additions & 1 deletion src/linkup/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,33 @@ class LinkupResearchTask(_LinkupBaseModel):
updated_at: str = pydantic.Field(validation_alias="updatedAt")


LinkupTask = LinkupSearchTask | LinkupFetchTask | LinkupResearchTask
class LinkupUnsupportedTask(_LinkupBaseModel):
"""A task returned by the API that this SDK does not model explicitly.

Attributes:
created_at: The task creation timestamp.
error: The task error message, if the task failed.
id: The task identifier.
input: The raw task input payload returned by the API.
output: The raw task output payload, if available.
raw_type: The original task type returned by the API.
status: The current task status.
type: The normalized task type, always "unsupported".
updated_at: The last task update timestamp.
"""

created_at: str = pydantic.Field(validation_alias="createdAt")
error: str | None = None
id: str
input: JSONObject
output: Any | None = None
raw_type: str = pydantic.Field(validation_alias="rawType")
status: Literal["pending", "processing", "completed", "failed"]
type: Literal["unsupported"]
updated_at: str = pydantic.Field(validation_alias="updatedAt")


LinkupTask = LinkupSearchTask | LinkupFetchTask | LinkupResearchTask | LinkupUnsupportedTask


class LinkupResearchTasksPage(_LinkupBaseModel):
Expand Down
88 changes: 88 additions & 0 deletions tests/unit/client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1492,6 +1492,62 @@ def test_list_tasks(mocker: MockerFixture, client: linkup.Client) -> None:
assert tasks_page.quota.in_flight == 1


def test_list_tasks_preserves_unsupported_task_variants(
mocker: MockerFixture, client: linkup.Client
) -> None:
mocker.patch(
"httpx.Client.request",
return_value=Response(
status_code=200,
content=b"""
{
"data": [
{
"createdAt": "2026-05-18T00:00:00.000Z",
"error": null,
"id": "extract-task-1",
"input": {
"q": "companies founded in paris",
"schema": {
"type": "object"
}
},
"output": {
"rows": [
{
"company": "Linkup"
}
],
"rowsReturned": 1
},
"status": "completed",
"type": "extract",
"updatedAt": "2026-05-18T00:00:00.000Z"
}
],
"metadata": {
"page": 1,
"pageSize": 10,
"total": 1,
"totalPages": 1
},
"quota": {
"inFlight": 1,
"limit": 100
}
}
""",
),
)

tasks_page = client.list_tasks()

assert isinstance(tasks_page.data[0], linkup.UnsupportedTask)
assert tasks_page.data[0].type == "unsupported"
assert tasks_page.data[0].raw_type == "extract"
assert tasks_page.data[0].output == {"rows": [{"company": "Linkup"}], "rowsReturned": 1}


def test_get_task_structured_search_output_keeps_search_results_shape_raw(
mocker: MockerFixture, client: linkup.Client
) -> None:
Expand Down Expand Up @@ -1567,6 +1623,38 @@ def test_get_task_structured_search_output_raw(
assert task.output == {"summary": "done"}


def test_get_task_preserves_unsupported_task_variant(
mocker: MockerFixture, client: linkup.Client
) -> None:
mocker.patch(
"httpx.Client.request",
return_value=Response(
status_code=200,
content=b"""
{
"createdAt": "2026-05-18T00:00:00.000Z",
"error": null,
"id": "extract-task-2",
"input": {
"q": "companies founded in paris"
},
"output": null,
"status": "processing",
"type": "extract",
"updatedAt": "2026-05-18T00:00:00.000Z"
}
""",
),
)

task = client.get_task("extract-task-2")

assert isinstance(task, linkup.UnsupportedTask)
assert task.type == "unsupported"
assert task.raw_type == "extract"
assert task.input == {"q": "companies founded in paris"}


def test_list_tasks_with_multiple_filters(mocker: MockerFixture, client: linkup.Client) -> None:
request_mock = mocker.patch(
"httpx.Client.request",
Expand Down
Loading