mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-11 10:01:41 +00:00
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:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## 2026.3.8
|
||||||
|
|
||||||
|
|||||||
@@ -396,6 +396,16 @@ type PerplexitySearchResponse = {
|
|||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
message?: {
|
message?: {
|
||||||
content?: string;
|
content?: string;
|
||||||
|
annotations?: Array<{
|
||||||
|
type?: string;
|
||||||
|
url?: string;
|
||||||
|
url_citation?: {
|
||||||
|
url?: string;
|
||||||
|
title?: string;
|
||||||
|
start_index?: number;
|
||||||
|
end_index?: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
citations?: string[];
|
citations?: string[];
|
||||||
@@ -414,6 +424,38 @@ type PerplexitySearchApiResponse = {
|
|||||||
id?: string;
|
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): {
|
function extractGrokContent(data: GrokSearchResponse): {
|
||||||
text: string | undefined;
|
text: string | undefined;
|
||||||
annotationCitations: string[];
|
annotationCitations: string[];
|
||||||
@@ -1252,7 +1294,8 @@ async function runPerplexitySearch(params: {
|
|||||||
|
|
||||||
const data = (await res.json()) as PerplexitySearchResponse;
|
const data = (await res.json()) as PerplexitySearchResponse;
|
||||||
const content = data.choices?.[0]?.message?.content ?? "No response";
|
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 };
|
return { content, citations };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,11 +113,13 @@ function installPerplexitySearchApiFetch(results?: Array<Record<string, unknown>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function installPerplexityChatFetch() {
|
function installPerplexityChatFetch(payload?: Record<string, unknown>) {
|
||||||
return installMockFetch({
|
return installMockFetch(
|
||||||
choices: [{ message: { content: "ok" } }],
|
payload ?? {
|
||||||
citations: ["https://example.com"],
|
choices: [{ message: { content: "ok" } }],
|
||||||
});
|
citations: ["https://example.com"],
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createProviderSuccessPayload(
|
function createProviderSuccessPayload(
|
||||||
@@ -509,6 +511,42 @@ describe("web_search perplexity OpenRouter compatibility", () => {
|
|||||||
expect(body.search_recency_filter).toBe("week");
|
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 () => {
|
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
|
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-v1-test"); // pragma: allowlist secret
|
||||||
const mockFetch = installPerplexityChatFetch();
|
const mockFetch = installPerplexityChatFetch();
|
||||||
|
|||||||
Reference in New Issue
Block a user