Files
wehub-resource-sync 2860fb5d18
Security / Dependency review (push) Has been skipped
Scorecard / Scorecard analysis (push) Failing after 0s
Validate / eval (push) Failing after 0s
Security / Dependency audit (push) Failing after 1s
Security / Secret scan (push) Failing after 1s
Validate / tests (push) Failing after 0s
Validate / mcp-tests (push) Failing after 1s
GitHub Actions Security Analysis with zizmor 🌈 / zizmor (push) Failing after 1s
Security / SAST scan (push) Failing after 13m52s
chore: import upstream snapshot with attribution
2026-07-13 12:05:33 +08:00

535 lines
22 KiB
Python

import unittest
from unittest.mock import patch
from lib import perplexity
class PerplexityProviderTests(unittest.TestCase):
def test_direct_perplexity_key_wins_and_parses_search_results(self):
response = {
"choices": [{"message": {"content": "Direct synthesis"}}],
"citations": ["https://example.com/a"],
"search_results": [
{
"title": "Example A",
"url": "https://example.com/a",
"date": "2026-06-01",
"snippet": "Direct source snippet",
"source": "web",
}
],
}
with patch("lib.perplexity.http.post", return_value=response) as post:
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{
"PERPLEXITY_API_KEY": "pplx-test",
"OPENROUTER_API_KEY": "or-test",
},
)
url, payload = post.call_args.args[:2]
headers = post.call_args.kwargs["headers"]
self.assertEqual(perplexity.PERPLEXITY_URL, url)
self.assertEqual("Bearer pplx-test", headers["Authorization"])
self.assertEqual("sonar-pro", payload["model"])
self.assertEqual(
"05/01/2026",
payload["web_search_options"]["search_after_date_filter"],
)
self.assertEqual(
"06/01/2026",
payload["web_search_options"]["search_before_date_filter"],
)
self.assertEqual("perplexity", artifact["provider"])
self.assertEqual("sonar-pro", artifact["model"])
self.assertEqual("Example A", items[1]["title"])
self.assertEqual("Direct source snippet", items[1]["snippet"])
def test_direct_model_config_selects_supported_sonar_model(self):
response = {
"choices": [{"message": {"content": "Reasoned synthesis"}}],
"citations": [],
"search_results": [],
}
with patch("lib.perplexity.http.post", return_value=response) as post:
_, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{
"PERPLEXITY_API_KEY": "pplx-test",
"LAST30DAYS_PERPLEXITY_MODEL": "sonar-reasoning-pro",
"LAST30DAYS_PERPLEXITY_REASONING_EFFORT": "high",
},
)
payload = post.call_args.args[1]
self.assertEqual("sonar-reasoning-pro", payload["model"])
self.assertEqual("high", payload["reasoning_effort"])
self.assertEqual("sonar-reasoning-pro", artifact["model"])
def test_openrouter_fallback_uses_openrouter_models_and_annotations(self):
response = {
"choices": [
{
"message": {
"content": "OpenRouter synthesis",
"annotations": [
{
"url_citation": {
"url": "https://example.com/b",
"title": "Example B",
}
}
],
}
}
],
}
with patch("lib.perplexity.http.post", return_value=response) as post:
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{"OPENROUTER_API_KEY": "or-test"},
deep=True,
)
url, payload = post.call_args.args[:2]
headers = post.call_args.kwargs["headers"]
self.assertEqual(perplexity.OPENROUTER_URL, url)
self.assertEqual("Bearer or-test", headers["Authorization"])
self.assertEqual("perplexity/sonar-deep-research", payload["model"])
self.assertEqual(120, post.call_args.kwargs["timeout"])
self.assertEqual("openrouter", artifact["provider"])
self.assertEqual("perplexity/sonar-deep-research", artifact["model"])
self.assertEqual("Example B", items[1]["title"])
def test_search_api_mode_returns_ranked_rows_with_filters(self):
response = {
"id": "search-1",
"server_time": "2026-06-01T00:00:00Z",
"results": [
{
"title": "Ranked result",
"url": "https://example.com/ranked",
"snippet": "Search API snippet",
"date": "2026-05-15",
"last_updated": "2026-05-20",
}
],
}
config = {
"PERPLEXITY_API_KEY": "pplx-test",
"LAST30DAYS_PERPLEXITY_MODE": "search",
"LAST30DAYS_PERPLEXITY_MAX_RESULTS": "3",
"LAST30DAYS_PERPLEXITY_SEARCH_CONTEXT_SIZE": "low",
"LAST30DAYS_PERPLEXITY_COUNTRY": "us",
"LAST30DAYS_PERPLEXITY_DOMAIN_FILTER": "example.com,example.org",
"LAST30DAYS_PERPLEXITY_LANGUAGE_FILTER": "en",
"LAST30DAYS_PERPLEXITY_RECENCY_FILTER": "year",
}
with patch("lib.perplexity.http.post", return_value=response) as post:
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
config,
)
url, payload = post.call_args.args[:2]
self.assertEqual(perplexity.PERPLEXITY_SEARCH_URL, url)
self.assertEqual("test topic", payload["query"])
self.assertEqual(3, payload["max_results"])
self.assertEqual("low", payload["search_context_size"])
self.assertEqual("US", payload["country"])
self.assertEqual(["example.com", "example.org"], payload["search_domain_filter"])
self.assertEqual("05/01/2026", payload["search_after_date_filter"])
self.assertEqual("06/01/2026", payload["search_before_date_filter"])
self.assertNotIn("search_recency_filter", payload)
self.assertEqual("search", artifact["mode"])
self.assertEqual("Ranked result", items[0]["title"])
self.assertEqual("2026-05-20", items[0]["metadata"]["last_updated"])
def test_search_api_keeps_recency_filter_when_no_exact_dates_are_available(self):
payload = perplexity._build_search_payload(
"test topic",
("not-a-date", "also-not-a-date"),
{"LAST30DAYS_PERPLEXITY_RECENCY_FILTER": "week"},
)
self.assertEqual("week", payload["search_recency_filter"])
self.assertNotIn("search_after_date_filter", payload)
self.assertNotIn("search_before_date_filter", payload)
def test_both_mode_keeps_synthesis_and_dedupes_raw_rows(self):
search_response = {
"id": "search-1",
"results": [
{
"title": "Duplicate ranked result",
"url": "https://example.com/a",
"snippet": "Raw row",
"date": "2026-05-15",
},
{
"title": "Unique ranked result",
"url": "https://example.com/unique",
"snippet": "Unique raw row",
"date": "2026-05-16",
},
],
}
sonar_response = {
"choices": [{"message": {"content": "Sonar synthesis"}}],
"citations": ["https://example.com/a"],
"search_results": [
{
"title": "Citation result",
"url": "https://example.com/a",
"snippet": "Citation row",
"date": "2026-05-15",
}
],
}
with patch(
"lib.perplexity.http.post",
side_effect=[search_response, sonar_response],
) as post:
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{
"PERPLEXITY_API_KEY": "pplx-test",
"LAST30DAYS_PERPLEXITY_MODE": "both",
},
)
self.assertEqual(perplexity.PERPLEXITY_SEARCH_URL, post.call_args_list[0].args[0])
self.assertEqual(perplexity.PERPLEXITY_URL, post.call_args_list[1].args[0])
self.assertEqual("both", artifact["mode"])
self.assertEqual(3, artifact["itemCount"])
self.assertEqual("perplexity.ai", items[0]["source_domain"])
urls = [item["url"] for item in items if item["url"]]
self.assertEqual(["https://example.com/a", "https://example.com/unique"], urls)
def test_both_mode_keeps_search_rows_when_sonar_leg_fails(self):
search_response = {
"id": "search-1",
"results": [
{
"title": "Raw result",
"url": "https://example.com/raw",
"snippet": "Raw row",
"date": "2026-05-15",
},
],
}
with patch(
"lib.perplexity.http.post",
side_effect=[
search_response,
perplexity.http.HTTPError("HTTP 500: Server Error", status_code=500),
],
):
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{
"PERPLEXITY_API_KEY": "pplx-test",
"LAST30DAYS_PERPLEXITY_MODE": "both",
},
)
self.assertEqual("Raw result", items[0]["title"])
self.assertEqual(1, artifact["itemCount"])
self.assertEqual("HTTPError", artifact["sonar"]["error"])
self.assertEqual(500, artifact["sonar"]["statusCode"])
def test_direct_deep_research_uses_async_api_and_wall_timeout_config(self):
create_response = {"id": "async-1", "status": "CREATED", "created_at": 123}
complete_response = {
"id": "async-1",
"status": "COMPLETED",
"created_at": 123,
"started_at": 124,
"completed_at": 130,
"response": {
"choices": [{"message": {"content": "Deep synthesis"}}],
"citations": ["https://example.com/deep"],
"search_results": [
{
"title": "Deep citation",
"url": "https://example.com/deep",
"snippet": "Deep snippet",
}
],
"usage": {
"total_tokens": 123,
"cost": {"total_cost": 0.12},
},
},
}
with patch("lib.perplexity.http.post", return_value=create_response) as post, \
patch("lib.perplexity.http.get", return_value=complete_response) as get:
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{
"PERPLEXITY_API_KEY": "pplx-test",
"LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "300",
},
deep=True,
)
self.assertEqual(perplexity.PERPLEXITY_ASYNC_URL, post.call_args.args[0])
self.assertEqual(
perplexity.PERPLEXITY_ASYNC_URL,
perplexity._provider({"PERPLEXITY_API_KEY": "pplx-test"}, deep=True)[2],
)
create_payload = post.call_args.args[1]
self.assertEqual("sonar-deep-research", create_payload["request"]["model"])
self.assertTrue(create_payload["idempotency_key"].startswith("last30days:"))
self.assertEqual(f"{perplexity.PERPLEXITY_ASYNC_URL}/async-1", get.call_args.args[0])
self.assertEqual("async-sonar", artifact["endpoint"])
self.assertEqual(True, artifact["async"])
self.assertEqual(300, artifact["asyncTimeoutSeconds"])
self.assertEqual(create_payload["idempotency_key"], artifact["asyncIdempotencyKey"])
self.assertEqual(1, artifact["asyncPollCount"])
self.assertEqual("COMPLETED_REMOTE", artifact["asyncLocalStatus"])
self.assertEqual(123, artifact["asyncCreatedAt"])
self.assertEqual(124, artifact["asyncStartedAt"])
self.assertEqual(130, artifact["asyncCompletedAt"])
self.assertEqual(123, items[0]["metadata"]["usage"]["total_tokens"])
def test_direct_deep_research_timeout_returns_empty_result(self):
with patch("lib.perplexity.http.post", return_value={"id": "async-1", "status": "CREATED", "created_at": 123}), \
patch("lib.perplexity.http.get", return_value={
"id": "async-1",
"status": "IN_PROGRESS",
"created_at": 123,
"started_at": 124,
}), \
patch("lib.perplexity.time.monotonic", side_effect=[0, 0, 2, 2]), \
patch("lib.perplexity.time.sleep"):
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{
"PERPLEXITY_API_KEY": "pplx-test",
"LAST30DAYS_PERPLEXITY_DEEP_TIMEOUT_SECONDS": "1",
},
deep=True,
)
self.assertEqual([], items)
self.assertEqual("timeout", artifact["error"])
self.assertEqual("async-1", artifact["asyncRequestId"])
self.assertEqual("IN_PROGRESS", artifact["asyncStatus"])
self.assertEqual(1, artifact["asyncTimeoutSeconds"])
self.assertEqual(1, artifact["asyncPollCount"])
self.assertEqual("PENDING_REMOTE", artifact["asyncLocalStatus"])
self.assertEqual(123, artifact["asyncCreatedAt"])
self.assertEqual(124, artifact["asyncStartedAt"])
def test_direct_deep_research_failed_status_returns_failure_artifact(self):
with patch("lib.perplexity.http.post", return_value={"id": "async-1", "status": "CREATED"}), \
patch("lib.perplexity.http.get", return_value={
"id": "async-1",
"status": "FAILED",
"failed_at": 130,
"error_message": "provider failure",
}):
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{"PERPLEXITY_API_KEY": "pplx-test"},
deep=True,
)
self.assertEqual([], items)
self.assertEqual("failed", artifact["error"])
self.assertEqual("async-1", artifact["asyncRequestId"])
self.assertEqual("FAILED", artifact["asyncStatus"])
self.assertEqual("FAILED_REMOTE", artifact["asyncLocalStatus"])
self.assertEqual(130, artifact["asyncFailedAt"])
self.assertEqual("provider failure", artifact["asyncErrorMessage"])
def test_direct_deep_research_poll_error_preserves_async_id(self):
with patch("lib.perplexity.http.post", return_value={
"id": "async-1",
"status": "CREATED",
"created_at": 123,
}), \
patch("lib.perplexity.http.get", side_effect=perplexity.http.HTTPError(
"HTTP 429: Too Many Requests",
status_code=429,
)):
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{"PERPLEXITY_API_KEY": "pplx-test"},
deep=True,
)
self.assertEqual([], items)
self.assertEqual("poll_error", artifact["error"])
self.assertEqual("async-1", artifact["asyncRequestId"])
self.assertEqual("CREATED", artifact["asyncStatus"])
self.assertEqual("POLL_ERROR", artifact["asyncLocalStatus"])
self.assertEqual(1, artifact["asyncPollCount"])
self.assertEqual(429, artifact["asyncPollStatusCode"])
def test_direct_deep_research_malformed_completed_preserves_async_id(self):
with patch("lib.perplexity.http.post", return_value={
"id": "async-1",
"status": "CREATED",
"created_at": 123,
}), \
patch("lib.perplexity.http.get", return_value={
"id": "async-1",
"status": "COMPLETED",
"created_at": 123,
"completed_at": 130,
"response": None,
}):
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{"PERPLEXITY_API_KEY": "pplx-test"},
deep=True,
)
self.assertEqual([], items)
self.assertEqual("failed", artifact["error"])
self.assertEqual("async-1", artifact["asyncRequestId"])
self.assertEqual("COMPLETED", artifact["asyncStatus"])
self.assertEqual("FAILED_REMOTE", artifact["asyncLocalStatus"])
self.assertEqual(1, artifact["asyncPollCount"])
self.assertEqual(130, artifact["asyncCompletedAt"])
self.assertEqual(
"Async Deep Research completed without response",
artifact["asyncErrorMessage"],
)
def test_direct_deep_research_empty_choices_preserves_async_id(self):
with patch("lib.perplexity.http.post", return_value={
"id": "async-1",
"status": "CREATED",
"created_at": 123,
}) as post, \
patch("lib.perplexity.http.get", return_value={
"id": "async-1",
"status": "COMPLETED",
"created_at": 123,
"completed_at": 130,
"response": {
"choices": [],
"usage": {"total_tokens": 321},
},
}):
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{"PERPLEXITY_API_KEY": "pplx-test"},
deep=True,
)
self.assertEqual([], items)
self.assertEqual("empty_choices", artifact["error"])
self.assertEqual("async-1", artifact["asyncRequestId"])
self.assertEqual("COMPLETED", artifact["asyncStatus"])
self.assertEqual("COMPLETED_REMOTE", artifact["asyncLocalStatus"])
self.assertEqual(1, artifact["asyncPollCount"])
self.assertEqual(130, artifact["asyncCompletedAt"])
self.assertEqual(
post.call_args.args[1]["idempotency_key"],
artifact["asyncIdempotencyKey"],
)
self.assertEqual(321, artifact["usage"]["total_tokens"])
self.assertEqual(
"Async Deep Research completed without choices",
artifact["asyncErrorMessage"],
)
def test_direct_deep_research_empty_synthesis_preserves_async_id(self):
with patch("lib.perplexity.http.post", return_value={
"id": "async-1",
"status": "CREATED",
"created_at": 123,
}) as post, \
patch("lib.perplexity.http.get", return_value={
"id": "async-1",
"status": "COMPLETED",
"created_at": 123,
"completed_at": 130,
"response": {
"choices": [{"message": {"content": ""}}],
},
}):
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{"PERPLEXITY_API_KEY": "pplx-test"},
deep=True,
)
self.assertEqual([], items)
self.assertEqual("empty_synthesis", artifact["error"])
self.assertEqual("async-1", artifact["asyncRequestId"])
self.assertEqual("COMPLETED", artifact["asyncStatus"])
self.assertEqual("COMPLETED_REMOTE", artifact["asyncLocalStatus"])
self.assertEqual(1, artifact["asyncPollCount"])
self.assertEqual(130, artifact["asyncCompletedAt"])
self.assertEqual(
post.call_args.args[1]["idempotency_key"],
artifact["asyncIdempotencyKey"],
)
self.assertEqual(
"Async Deep Research completed with empty synthesis",
artifact["asyncErrorMessage"],
)
def test_direct_deep_research_malformed_choice_preserves_async_id(self):
with patch("lib.perplexity.http.post", return_value={
"id": "async-1",
"status": "CREATED",
"created_at": 123,
}) as post, \
patch("lib.perplexity.http.get", return_value={
"id": "async-1",
"status": "COMPLETED",
"created_at": 123,
"completed_at": 130,
"response": {
"choices": [None],
},
}):
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{"PERPLEXITY_API_KEY": "pplx-test"},
deep=True,
)
self.assertEqual([], items)
self.assertEqual("empty_synthesis", artifact["error"])
self.assertEqual("async-1", artifact["asyncRequestId"])
self.assertEqual("COMPLETED", artifact["asyncStatus"])
self.assertEqual("COMPLETED_REMOTE", artifact["asyncLocalStatus"])
self.assertEqual(
post.call_args.args[1]["idempotency_key"],
artifact["asyncIdempotencyKey"],
)
def test_missing_keys_skip_without_http(self):
with patch("lib.perplexity.http.post") as post:
items, artifact = perplexity.search(
"test topic",
("2026-05-01", "2026-06-01"),
{},
)
post.assert_not_called()
self.assertEqual([], items)
self.assertEqual({}, artifact)