fix(web-search): recover OpenRouter Perplexity citations from message annotations (#40881)

Merged via squash.

Prepared head SHA: 66c8bb2c6a
Co-authored-by: laurieluo <89195476+laurieluo@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
Laurie Luo
2026-03-10 13:07:44 +08:00
committed by GitHub
parent 382287026b
commit cf9db91b61
3 changed files with 88 additions and 6 deletions

View File

@@ -48,6 +48,7 @@ Docs: https://docs.openclaw.ai
- Control UI/Sessions: restore single-column session table collapse on narrow viewport or container widths by moving the responsive table override next to the base grid rule and enabling inline-size container queries. (#12175) Thanks @benjipeng.
- Telegram/final preview delivery: split active preview lifecycle from cleanup retention so missing archived preview edits avoid duplicate fallback sends without clearing the live preview or blocking later in-place finalization. (#41662) thanks @hougangdev.
- Cron/state errors: record `lastErrorReason` in cron job state and keep the gateway schema aligned with the full failover-reason set, including regression coverage for protocol conformance. (#14382) thanks @futuremind2026.
- Tools/web search: recover OpenRouter Perplexity citation extraction from `message.annotations` when chat-completions responses omit top-level citations. (#40881) Thanks @laurieluo.
## 2026.3.8

View File

@@ -396,6 +396,16 @@ type PerplexitySearchResponse = {
choices?: Array<{
message?: {
content?: string;
annotations?: Array<{
type?: string;
url?: string;
url_citation?: {
url?: string;
title?: string;
start_index?: number;
end_index?: number;
};
}>;
};
}>;
citations?: string[];
@@ -414,6 +424,38 @@ type PerplexitySearchApiResponse = {
id?: string;
};
function extractPerplexityCitations(data: PerplexitySearchResponse): string[] {
const normalizeUrl = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
};
const topLevel = (data.citations ?? [])
.map(normalizeUrl)
.filter((url): url is string => Boolean(url));
if (topLevel.length > 0) {
return [...new Set(topLevel)];
}
const citations: string[] = [];
for (const choice of data.choices ?? []) {
for (const annotation of choice.message?.annotations ?? []) {
if (annotation.type !== "url_citation") {
continue;
}
const url = normalizeUrl(annotation.url_citation?.url ?? annotation.url);
if (url) {
citations.push(url);
}
}
}
return [...new Set(citations)];
}
function extractGrokContent(data: GrokSearchResponse): {
text: string | undefined;
annotationCitations: string[];
@@ -1252,7 +1294,8 @@ async function runPerplexitySearch(params: {
const data = (await res.json()) as PerplexitySearchResponse;
const content = data.choices?.[0]?.message?.content ?? "No response";
const citations = data.citations ?? [];
// Prefer top-level citations; fall back to OpenRouter-style message annotations.
const citations = extractPerplexityCitations(data);
return { content, citations };
},

View File

@@ -113,11 +113,13 @@ function installPerplexitySearchApiFetch(results?: Array<Record<string, unknown>
});
}
function installPerplexityChatFetch() {
return installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: ["https://example.com"],
});
function installPerplexityChatFetch(payload?: Record<string, unknown>) {
return installMockFetch(
payload ?? {
choices: [{ message: { content: "ok" } }],
citations: ["https://example.com"],
},
);
}
function createProviderSuccessPayload(
@@ -509,6 +511,42 @@ describe("web_search perplexity OpenRouter compatibility", () => {
expect(body.search_recency_filter).toBe("week");
});
it("falls back to message annotations when top-level citations are missing", async () => {
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
const mockFetch = installPerplexityChatFetch({
choices: [
{
message: {
content: "ok",
annotations: [
{
type: "url_citation",
url_citation: { url: "https://example.com/a" },
},
{
type: "url_citation",
url_citation: { url: "https://example.com/b" },
},
{
type: "url_citation",
url_citation: { url: "https://example.com/a" },
},
],
},
},
],
});
const tool = createPerplexitySearchTool();
const result = await tool?.execute?.("call-1", { query: "test" });
expect(mockFetch).toHaveBeenCalled();
expect(result?.details).toMatchObject({
provider: "perplexity",
citations: ["https://example.com/a", "https://example.com/b"],
content: expect.stringContaining("ok"),
});
});
it("fails loud for Search API-only filters on the compatibility path", async () => {
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
const mockFetch = installPerplexityChatFetch();