fix(web-search): handle xAI Responses API format in Grok provider

The xAI /v1/responses API returns content in a structured format with
typed output blocks (type: 'message') containing typed content blocks
(type: 'output_text') and url_citation annotations. The previous code
only checked output[0].content[0].text without filtering by type,
which could miss content in responses with multiple output entries.

Changes:
- Update GrokSearchResponse type to include annotations on content blocks
- Filter output blocks by type='message' and content by type='output_text'
- Extract url_citation annotations as fallback citations when top-level
  citations array is empty
- Deduplicate annotation-derived citation URLs
- Update tests for the new structured return type

Closes #13520
This commit is contained in:
Rain
2026-02-11 01:57:22 +08:00
committed by Peter Steinberger
parent a36b9be245
commit 27453f5a31
2 changed files with 89 additions and 21 deletions

View File

@@ -145,21 +145,66 @@ describe("web_search grok config resolution", () => {
}); });
describe("web_search grok response parsing", () => { describe("web_search grok response parsing", () => {
it("extracts content from Responses API output blocks", () => { it("extracts content from Responses API message blocks", () => {
expect( const result = extractGrokContent({
extractGrokContent({ output: [
output: [ {
{ type: "message",
content: [{ text: "hello from output" }], content: [{ type: "output_text", text: "hello from output" }],
}, },
], ],
}), });
).toBe("hello from output"); expect(result.text).toBe("hello from output");
expect(result.annotationCitations).toEqual([]);
});
it("extracts url_citation annotations from content blocks", () => {
const result = extractGrokContent({
output: [
{
type: "message",
content: [
{
type: "output_text",
text: "hello with citations",
annotations: [
{
type: "url_citation",
url: "https://example.com/a",
start_index: 0,
end_index: 5,
},
{
type: "url_citation",
url: "https://example.com/b",
start_index: 6,
end_index: 10,
},
{
type: "url_citation",
url: "https://example.com/a",
start_index: 11,
end_index: 15,
}, // duplicate
],
},
],
},
],
});
expect(result.text).toBe("hello with citations");
expect(result.annotationCitations).toEqual(["https://example.com/a", "https://example.com/b"]);
}); });
it("falls back to deprecated output_text", () => { it("falls back to deprecated output_text", () => {
expect(extractGrokContent({ output_text: "hello from output_text" })).toBe( const result = extractGrokContent({ output_text: "hello from output_text" });
"hello from output_text", expect(result.text).toBe("hello from output_text");
); expect(result.annotationCitations).toEqual([]);
});
it("returns undefined text when no content found", () => {
const result = extractGrokContent({});
expect(result.text).toBeUndefined();
expect(result.annotationCitations).toEqual([]);
}); });
}); });

View File

@@ -109,6 +109,12 @@ type GrokSearchResponse = {
content?: Array<{ content?: Array<{
type?: string; type?: string;
text?: string; text?: string;
annotations?: Array<{
type?: string;
url?: string;
start_index?: number;
end_index?: number;
}>;
}>; }>;
}>; }>;
output_text?: string; // deprecated field - kept for backwards compatibility output_text?: string; // deprecated field - kept for backwards compatibility
@@ -131,13 +137,28 @@ type PerplexitySearchResponse = {
type PerplexityBaseUrlHint = "direct" | "openrouter"; type PerplexityBaseUrlHint = "direct" | "openrouter";
function extractGrokContent(data: GrokSearchResponse): string | undefined { function extractGrokContent(data: GrokSearchResponse): {
// xAI Responses API format: output[0].content[0].text text: string | undefined;
const fromResponses = data.output?.[0]?.content?.[0]?.text; annotationCitations: string[];
if (typeof fromResponses === "string" && fromResponses) { } {
return fromResponses; // xAI Responses API format: find the message output with text content
for (const output of data.output ?? []) {
if (output.type !== "message") {
continue;
}
for (const block of output.content ?? []) {
if (block.type === "output_text" && typeof block.text === "string" && block.text) {
// Extract url_citation annotations from this content block
const urls = (block.annotations ?? [])
.filter((a) => a.type === "url_citation" && typeof a.url === "string")
.map((a) => a.url as string);
return { text: block.text, annotationCitations: [...new Set(urls)] };
}
}
} }
return typeof data.output_text === "string" ? data.output_text : undefined; // Fallback: deprecated output_text field
const text = typeof data.output_text === "string" ? data.output_text : undefined;
return { text, annotationCitations: [] };
} }
function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig { function resolveSearchConfig(cfg?: OpenClawConfig): WebSearchConfig {
@@ -494,8 +515,10 @@ async function runGrokSearch(params: {
} }
const data = (await res.json()) as GrokSearchResponse; const data = (await res.json()) as GrokSearchResponse;
const content = extractGrokContent(data) ?? "No response"; const { text: extractedText, annotationCitations } = extractGrokContent(data);
const citations = data.citations ?? []; const content = extractedText ?? "No response";
// Prefer top-level citations; fall back to annotation-derived ones
const citations = (data.citations ?? []).length > 0 ? data.citations! : annotationCitations;
const inlineCitations = data.inline_citations; const inlineCitations = data.inline_citations;
return { content, citations, inlineCitations }; return { content, citations, inlineCitations };