Compare commits
2 Commits
vincentkoc
...
fix/grok-w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a20f3d6946 | ||
|
|
481815cb7e |
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- Version alignment: bump manifests and package versions to `2026.2.10`; keep `appcast.xml` unchanged until the next macOS release cut.
|
||||
- Tools/web_search: handle xAI Responses API message/output parsing and citation extraction for Grok provider. (#13547) Thanks @0xRaini.
|
||||
|
||||
## 2026.2.9
|
||||
|
||||
|
||||
113
src/agents/tools/web-search.grok.live.test.ts
Normal file
113
src/agents/tools/web-search.grok.live.test.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isTruthyEnvValue } from "../../infra/env.js";
|
||||
import { __testing } from "./web-search.js";
|
||||
|
||||
const LIVE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST) || isTruthyEnvValue(process.env.LIVE);
|
||||
const XAI_KEY = process.env.XAI_API_KEY?.trim() ?? "";
|
||||
const GROK_MODEL = process.env.OPENCLAW_LIVE_GROK_MODEL?.trim() || "grok-4-1-fast";
|
||||
const XAI_RESPONSES_API = "https://api.x.ai/v1/responses";
|
||||
|
||||
type ParsedResponse = {
|
||||
text?: string;
|
||||
annotationCitations?: string[];
|
||||
};
|
||||
|
||||
const describeLive = LIVE && XAI_KEY ? describe : describe.skip;
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.filter((item): item is string => typeof item === "string" && item.length > 0);
|
||||
}
|
||||
|
||||
function legacyExtractText(data: unknown): string | undefined {
|
||||
if (!data || typeof data !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const value = (data as { output?: Array<{ content?: Array<{ text?: unknown }> }> }).output?.[0]
|
||||
?.content?.[0]?.text;
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeExtractResult(raw: unknown): {
|
||||
text: string | undefined;
|
||||
annotationCitations: string[];
|
||||
} {
|
||||
if (typeof raw === "string") {
|
||||
return { text: raw, annotationCitations: [] };
|
||||
}
|
||||
if (!raw || typeof raw !== "object") {
|
||||
return { text: undefined, annotationCitations: [] };
|
||||
}
|
||||
const parsed = raw as ParsedResponse;
|
||||
return {
|
||||
text: typeof parsed.text === "string" ? parsed.text : undefined,
|
||||
annotationCitations: asStringArray(parsed.annotationCitations),
|
||||
};
|
||||
}
|
||||
|
||||
async function callXaiResponses(params: {
|
||||
body: Record<string, unknown>;
|
||||
timeoutMs: number;
|
||||
}): Promise<{ status: number; ok: boolean; data?: Record<string, unknown>; detail?: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), params.timeoutMs);
|
||||
timeout.unref?.();
|
||||
try {
|
||||
const res = await fetch(XAI_RESPONSES_API, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${XAI_KEY}`,
|
||||
},
|
||||
body: JSON.stringify(params.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { status: res.status, ok: false, detail: await res.text() };
|
||||
}
|
||||
return { status: res.status, ok: true, data: (await res.json()) as Record<string, unknown> };
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
describeLive("web_search grok live", () => {
|
||||
it("extracts text from xAI Responses API payloads", async () => {
|
||||
const request: Record<string, unknown> = {
|
||||
model: GROK_MODEL,
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"Search the web for the latest OpenAI API docs URL. Reply in one sentence and include source links.",
|
||||
},
|
||||
],
|
||||
tools: [{ type: "web_search" }],
|
||||
include: ["inline_citations"],
|
||||
};
|
||||
|
||||
let result = await callXaiResponses({ body: request, timeoutMs: 45_000 });
|
||||
if (
|
||||
!result.ok &&
|
||||
result.status === 400 &&
|
||||
typeof result.detail === "string" &&
|
||||
result.detail.includes("Argument not supported: include")
|
||||
) {
|
||||
const retryRequest = { ...request };
|
||||
delete retryRequest.include;
|
||||
result = await callXaiResponses({ body: retryRequest, timeoutMs: 45_000 });
|
||||
}
|
||||
|
||||
expect(result.ok, result.detail ?? "xAI request failed").toBe(true);
|
||||
const data = result.data as Record<string, unknown>;
|
||||
const parsed = normalizeExtractResult(__testing.extractGrokContent(data as never));
|
||||
const legacyText = legacyExtractText(data);
|
||||
|
||||
expect(parsed.text && parsed.text.trim().length > 0).toBe(true);
|
||||
if (!legacyText) {
|
||||
expect(parsed.text && parsed.text.trim().length > 0).toBe(true);
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -145,21 +145,83 @@ describe("web_search grok config resolution", () => {
|
||||
});
|
||||
|
||||
describe("web_search grok response parsing", () => {
|
||||
it("extracts content from Responses API output blocks", () => {
|
||||
expect(
|
||||
extractGrokContent({
|
||||
output: [
|
||||
{
|
||||
content: [{ text: "hello from output" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe("hello from output");
|
||||
it("skips non-message output entries and extracts from message output", () => {
|
||||
const result = extractGrokContent({
|
||||
output: [
|
||||
{
|
||||
type: "reasoning",
|
||||
content: [],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
content: [{ type: "output_text", text: "hello from message output" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.text).toBe("hello from message output");
|
||||
expect(result.annotationCitations).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts content from Responses API message blocks", () => {
|
||||
const result = extractGrokContent({
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
content: [{ type: "output_text", text: "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", () => {
|
||||
expect(extractGrokContent({ output_text: "hello from output_text" })).toBe(
|
||||
"hello from output_text",
|
||||
);
|
||||
const result = extractGrokContent({ 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,6 +109,12 @@ type GrokSearchResponse = {
|
||||
content?: Array<{
|
||||
type?: string;
|
||||
text?: string;
|
||||
annotations?: Array<{
|
||||
type?: string;
|
||||
url?: string;
|
||||
start_index?: number;
|
||||
end_index?: number;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
output_text?: string; // deprecated field - kept for backwards compatibility
|
||||
@@ -131,13 +137,28 @@ type PerplexitySearchResponse = {
|
||||
|
||||
type PerplexityBaseUrlHint = "direct" | "openrouter";
|
||||
|
||||
function extractGrokContent(data: GrokSearchResponse): string | undefined {
|
||||
// xAI Responses API format: output[0].content[0].text
|
||||
const fromResponses = data.output?.[0]?.content?.[0]?.text;
|
||||
if (typeof fromResponses === "string" && fromResponses) {
|
||||
return fromResponses;
|
||||
function extractGrokContent(data: GrokSearchResponse): {
|
||||
text: string | undefined;
|
||||
annotationCitations: string[];
|
||||
} {
|
||||
// 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 {
|
||||
@@ -494,8 +515,10 @@ async function runGrokSearch(params: {
|
||||
}
|
||||
|
||||
const data = (await res.json()) as GrokSearchResponse;
|
||||
const content = extractGrokContent(data) ?? "No response";
|
||||
const citations = data.citations ?? [];
|
||||
const { text: extractedText, annotationCitations } = extractGrokContent(data);
|
||||
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;
|
||||
|
||||
return { content, citations, inlineCitations };
|
||||
|
||||
Reference in New Issue
Block a user