feat(coding-agent/web): expanded web search and fetching providers

- Expanded web search and fetching providers with robust parsing, authentication storage integration, and response validation.
- Added support for new configurations including SearXNG safesearch, Cloudflare AI Gateway endpoints, and dynamic Firecrawl base URLs.
- Implemented comprehensive test suites covering error handling, content filtering, and provider-specific response behaviors.
This commit is contained in:
can1357
2026-08-10 11:06:22 +02:00
parent 45e12e5bb7
commit baf8a1df7e
31 changed files with 1660 additions and 224 deletions
@@ -94,3 +94,63 @@ describe("renderHtmlToText: jina stall does not starve local fallbacks (#1449)",
).toBe(true);
});
});
describe("renderHtmlToText: Jina response validation", () => {
it("requests fresh markdown and strips the Jina metadata preamble", async () => {
const settings = Settings.isolated({ "providers.fetch": "jina" });
const markdown = `# Extracted article\n\n${"Substantive reader content. ".repeat(8)}`.trim();
let requestHeaders: Headers | undefined;
const fetchMock = asGlobalFetch((_input, init) => {
requestHeaders = new Headers(init?.headers);
return new Response(`Title: Example\nURL Source: https://example.com/article\nMarkdown Content:\n${markdown}`);
});
const result = await renderHtmlToText(
"https://example.com/article",
"<html><body>short</body></html>",
1,
settings,
undefined,
null,
fetchMock,
);
expect(result).toEqual({ content: markdown, ok: true, method: "jina" });
expect(requestHeaders?.get("accept")).toBe("text/markdown");
expect(requestHeaders?.get("x-no-cache")).toBe("true");
});
for (const { label, readerBody, headers } of [
{ label: "missing marker", readerBody: "Plausible but unstructured output. ".repeat(8) },
{ label: "short body", readerBody: "Markdown Content:\nToo short" },
{ label: "loading shell", readerBody: `Markdown Content:\nLoading...${" ".repeat(120)}` },
{ label: "JavaScript gate", readerBody: `Markdown Content:\nPlease enable JavaScript${" ".repeat(120)}` },
{
label: "declared oversized body",
readerBody: `Markdown Content:\n${"Substantive content. ".repeat(8)}`,
headers: { "Content-Length": String(2 * 1024 * 1024 + 1) },
},
]) {
it(`falls back when Jina returns a ${label}`, async () => {
const settings = Settings.isolated({ "providers.fetch": "jina" });
const paragraph =
"This locally rendered article contains enough meaningful prose to satisfy the shared reader quality gate. ";
const html = `<html><body><article><h1>Fallback article</h1><p>${paragraph.repeat(4)}</p></article></body></html>`;
const fetchMock = asGlobalFetch(() => new Response(readerBody, { headers }));
const result = await renderHtmlToText(
"https://example.com/article",
html,
1,
settings,
undefined,
null,
fetchMock,
);
expect(result.ok).toBe(true);
expect(result.method).toBe("native");
expect(result.content).toContain("Fallback article");
});
}
});
@@ -531,6 +531,70 @@ describe("searchCodex model selection", () => {
expect(result.sources).toEqual([{ title: "Example Article", url: "https://example.com/article" }]);
});
it("requests and merges web-search action sources with citation metadata", async () => {
process.env.PI_CODEX_WEB_SEARCH_MODEL = "gpt-5.4";
const answer = "The Responses API supports hosted web search.";
const citationStart = answer.indexOf("hosted web search");
const sse = [
`data: ${JSON.stringify({
type: "response.created",
response: { id: "resp_created_id", model: "gpt-5.4" },
})}`,
"",
`data: ${JSON.stringify({
type: "response.output_item.done",
item: {
type: "web_search_call",
action: {
sources: [
{
url: "https://example.com/article?utm_source=openai",
title: "Search result title",
},
],
},
},
})}`,
"",
`data: ${JSON.stringify({
type: "response.output_item.done",
item: {
type: "message",
content: [
{
type: "output_text",
text: answer,
annotations: [
{
type: "url_citation",
url: "https://example.com/article?utm_source=openai",
title: "Example Article",
start_index: citationStart,
end_index: citationStart + "hosted web search".length,
},
],
},
],
},
})}`,
"",
].join("\n");
const result = await searchCodex(
makeSearchParams("action sources", mockCodexFetch("gpt-5.4", sse)),
);
expect(capturedRequest?.body?.include).toEqual(["web_search_call.action.sources"]);
expect(result.requestId).toBe("resp_created_id");
expect(result.sources).toEqual([
{
title: "Search result title",
url: "https://example.com/article",
snippet: answer,
},
]);
});
it("extracts plain text URLs when annotations are absent", async () => {
process.env.PI_CODEX_WEB_SEARCH_MODEL = "gpt-5.4";
const result = await searchCodex(
@@ -769,4 +833,22 @@ describe("searchCodex model selection", () => {
"Codex request failed (model_snapshot_unavailable): The requested model snapshot is unavailable.",
);
});
it("classifies rate-limit failures delivered inside a successful SSE response", async () => {
const sse = [
`data: ${JSON.stringify({
type: "response.failed",
response: {
error: { code: "rate_limit_exceeded", message: "Too many requests" },
},
})}`,
"",
].join("\n");
const fetchMock: FetchImpl = () =>
Promise.resolve(new Response(sse, { status: 200, headers: { "Content-Type": "text/event-stream" } }));
await expect(searchCodex(makeSearchParams("rate-limited search", fetchMock))).rejects.toMatchObject({
status: 429,
});
});
});
@@ -428,6 +428,19 @@ describe("searchExa", () => {
expect(result.sources[0].snippet).toBe("summary here");
});
it("caps snippets at 500 characters", async () => {
const result = await searchExa({
query: "bounded snippet",
fetch: mockFetch(
makeMockExaResponse({
results: [{ title: "Long", url: "https://long.example", summary: "x".repeat(800) }],
}),
),
});
expect(result.sources[0].snippet).toHaveLength(500);
});
it("falls back to text when summary is null", async () => {
const result = await searchExa({
query: "fallback",
@@ -535,6 +548,68 @@ describe("searchExa", () => {
});
});
it("encodes MCP filters in the basic query, uses camel-case result count, and tags the request source", async () => {
delete process.env.EXA_API_KEY;
let headers: Record<string, string> | undefined;
const fetchMock: FetchImpl = (_url, init) => {
headers = init?.headers as Record<string, string> | undefined;
if (init?.body) capturedRequestBody = JSON.parse(init.body as string);
return Promise.resolve(
new Response(JSON.stringify({ jsonrpc: "2.0", id: "mcp-filtered", result: makeMockExaResponse() }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
};
await searchExa({
query: "vector databases",
num_results: 4,
include_domains: [" qdrant.tech "],
exclude_domains: ["spam.example"],
start_published_date: "2024-01-01",
end_published_date: "2025-01-01",
fetch: fetchMock,
});
expect(headers?.["x-exa-source"]).toBe("oh-my-pi");
expect(capturedRequestBody?.params).toEqual({
name: "web_search_exa",
arguments: {
query:
"vector databases site:qdrant.tech -site:spam.example after:2024-01-01 before:2025-01-01",
numResults: 4,
},
});
});
it("explains how to escape the keyless MCP rate limit", async () => {
delete process.env.EXA_API_KEY;
const fetchMock: FetchImpl = () =>
Promise.resolve(new Response("too many requests", { status: 429, statusText: "Too Many Requests" }));
await expect(searchExa({ query: "rate limited", fetch: fetchMock })).rejects.toThrow(
"exa: MCP rate limit reached (429); configure an Exa API key for higher limits",
);
});
it("surfaces MCP tool-level errors", async () => {
delete process.env.EXA_API_KEY;
const fetchMock: FetchImpl = () =>
Promise.resolve(
new Response(
JSON.stringify({
jsonrpc: "2.0",
id: "mcp-error",
result: { isError: true, content: [{ type: "text", text: "tool quota exceeded" }] },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
await expect(searchExa({ query: "tool error", fetch: fetchMock })).rejects.toThrow("tool quota exceeded");
});
it("parses Exa MCP plain-text payloads when API key is missing", async () => {
delete process.env.EXA_API_KEY;
const fetchMock: FetchImpl = () => {
@@ -235,18 +235,58 @@ describe("Firecrawl web search provider", () => {
}
});
it("keeps keyless Firecrawl out of auto selection while allowing explicit selection", () => {
it("keeps hosted keyless Firecrawl explicit-only but admits configured self-hosting", () => {
const originalApiKey = process.env.FIRECRAWL_API_KEY;
const originalBaseUrl = process.env.FIRECRAWL_BASE_URL;
const originalApiUrl = process.env.FIRECRAWL_API_URL;
delete process.env.FIRECRAWL_API_KEY;
delete process.env.FIRECRAWL_BASE_URL;
delete process.env.FIRECRAWL_API_URL;
try {
const provider = new FirecrawlProvider();
const authStorage = makeAuthStorage(undefined);
expect(provider.isAvailable(authStorage)).toBe(false);
expect(provider.isExplicitlyAvailable(authStorage)).toBe(true);
process.env.FIRECRAWL_BASE_URL = "http://localhost:3002";
expect(provider.isAvailable(authStorage)).toBe(true);
} finally {
if (originalApiKey === undefined) delete process.env.FIRECRAWL_API_KEY;
else process.env.FIRECRAWL_API_KEY = originalApiKey;
if (originalBaseUrl === undefined) delete process.env.FIRECRAWL_BASE_URL;
else process.env.FIRECRAWL_BASE_URL = originalBaseUrl;
if (originalApiUrl === undefined) delete process.env.FIRECRAWL_API_URL;
else process.env.FIRECRAWL_API_URL = originalApiUrl;
}
});
it("uses a self-hosted endpoint and accepts Firecrawl v1 array responses", async () => {
const originalBaseUrl = process.env.FIRECRAWL_BASE_URL;
process.env.FIRECRAWL_BASE_URL = "http://localhost:3002/v1/";
let requestUrl = "";
try {
const fetchMock: FetchImpl = async input => {
requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
return new Response(
JSON.stringify({
success: true,
data: [{ title: "Legacy result", url: "https://example.com/legacy", snippet: "Legacy snippet" }],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
};
const response = await searchFirecrawl({
...makeParams("legacy query", makeAuthStorage(undefined)),
fetch: fetchMock,
});
expect(requestUrl).toBe("http://localhost:3002/v1/search");
expect(response.sources).toEqual([
{ title: "Legacy result", url: "https://example.com/legacy", snippet: "Legacy snippet" },
]);
} finally {
if (originalBaseUrl === undefined) delete process.env.FIRECRAWL_BASE_URL;
else process.env.FIRECRAWL_BASE_URL = originalBaseUrl;
}
});
@@ -10,6 +10,7 @@ const DEVELOPER_SSE_RESPONSE =
const DEVELOPER_SSE_RESPONSE_WITHOUT_MODEL =
'data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Developer answer"}]},"groundingMetadata":{"webSearchQueries":["latest Bun version"],"groundingChunks":[{"web":{"uri":"https://bun.sh","title":"Bun"}}],"groundingSupports":[{"segment":{"text":"Developer answer"},"groundingChunkIndices":[0]}]}}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":4,"totalTokenCount":7}}\n\n';
const ORIGINAL_GEMINI_SEARCH_MODEL = Bun.env.GEMINI_SEARCH_MODEL;
const ORIGINAL_GEMINI_BASE_URL = Bun.env.GOOGLE_GEMINI_BASE_URL;
type CapturedRequest = {
url: string;
@@ -72,6 +73,11 @@ describe("searchGemini tools serialization", () => {
} else {
Bun.env.GEMINI_SEARCH_MODEL = ORIGINAL_GEMINI_SEARCH_MODEL;
}
if (ORIGINAL_GEMINI_BASE_URL === undefined) {
delete Bun.env.GOOGLE_GEMINI_BASE_URL;
} else {
Bun.env.GOOGLE_GEMINI_BASE_URL = ORIGINAL_GEMINI_BASE_URL;
}
});
function makeParams(query: string) {
@@ -111,6 +117,61 @@ describe("searchGemini tools serialization", () => {
});
});
it("routes Cloudflare AI Gateway auth through AuthStorage without leaking a Google API key", async () => {
Bun.env.GOOGLE_GEMINI_BASE_URL =
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-ai-studio";
const gatewayAuthStorage = {
async getOAuthAccess() {
return undefined;
},
hasOAuth() {
return false;
},
hasAuth(provider: string) {
return provider === "cloudflare-ai-gateway";
},
async getApiKey(provider: string) {
return provider === "cloudflare-ai-gateway" ? "test-cloudflare-key" : undefined;
},
} as unknown as AuthStorage;
const fetchMock = mockGeminiFetch(DEVELOPER_SSE_RESPONSE);
expect(new GeminiProvider().isAvailable(gatewayAuthStorage)).toBe(true);
await searchGemini({
...makeParams("gateway"),
authStorage: gatewayAuthStorage,
fetch: fetchMock,
});
expect(capturedRequest?.url).toBe(
"https://gateway.ai.cloudflare.com/v1/account/gateway/google-ai-studio/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
);
expect(capturedRequest?.headers["cf-aig-authorization"]).toBe("Bearer test-cloudflare-key");
expect(capturedRequest?.headers["x-goog-api-key"]).toBeUndefined();
});
it("redacts the active credential from Gemini API errors", async () => {
let thrown: unknown;
try {
await searchGemini({
...makeParams("redaction"),
authStorage: apiKeyAuthStorage,
fetch: () =>
Promise.resolve(
new Response("upstream echoed test-gemini-api-key", {
status: 418,
}),
),
});
} catch (error) {
thrown = error;
}
expect(thrown).toBeInstanceOf(Error);
expect((thrown as Error).message).toContain("[redacted]");
expect((thrown as Error).message).not.toContain("test-gemini-api-key");
});
it("normalizes query directive aliases to canonical Google forms in the grounding request", async () => {
const fetchMock = mockGeminiFetch();
await searchGemini({
@@ -221,4 +282,55 @@ describe("searchGemini tools serialization", () => {
tools: [{ googleSearch: {} }, { codeExecution: {} }, { urlContext: { allowedDomains: ["example.com"] } }],
});
});
it("resolves Google grounding proxy URLs in both sources and citations", async () => {
const proxyUrl =
"https://vertexaisearch.cloud.google.com/grounding-api-redirect/abc";
const responseText = `data: ${JSON.stringify({
candidates: [
{
content: { role: "model", parts: [{ text: "Grounded answer" }] },
groundingMetadata: {
groundingChunks: [{ web: { uri: proxyUrl, title: "Example" } }],
groundingSupports: [{ segment: { text: "Grounded answer" }, groundingChunkIndices: [0] }],
},
},
],
})}\n\n`;
const methods: string[] = [];
const fetchMock: FetchImpl = (_url, init) => {
methods.push(init?.method ?? "GET");
if (init?.method === "HEAD") {
return Promise.resolve(
new Response(null, {
status: 302,
headers: { location: "https://example.com/article" },
}),
);
}
return Promise.resolve(new Response(responseText, { status: 200 }));
};
const response = await searchGemini({
...makeParams("grounding redirect"),
authStorage: apiKeyAuthStorage,
fetch: fetchMock,
});
expect(methods).toEqual(["POST", "HEAD"]);
expect(response.sources).toEqual([{ title: "Example", url: "https://example.com/article" }]);
expect(response.citations).toEqual([
{ title: "Example", url: "https://example.com/article", citedText: "Grounded answer" },
]);
});
it("rejects a successful Gemini response with no answer or grounding results", async () => {
await expect(
searchGemini({
...makeParams("empty"),
authStorage: apiKeyAuthStorage,
fetch: mockGeminiFetch("data: {}\n\n"),
}),
).rejects.toThrow("Gemini API returned an empty grounded response");
});
});
@@ -58,6 +58,34 @@ describe("Kagi web search error handling", () => {
"Kagi API error (502)",
);
});
it("reports malformed success responses as Kagi API errors", async () => {
const invalidJsonFetch: FetchImpl = async () => new Response("<html>not json</html>", { status: 200 });
const invalidEnvelopeFetch: FetchImpl = async () =>
new Response(JSON.stringify(["unexpected"]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
await expect(searchWithKagi("invalid json", { fetch: invalidJsonFetch }, fakeAuthStorage)).rejects.toThrow(
"Kagi API returned an invalid response: invalid JSON",
);
await expect(searchWithKagi("invalid envelope", { fetch: invalidEnvelopeFetch }, fakeAuthStorage)).rejects.toThrow(
"Kagi API returned an invalid response: expected an object envelope",
);
});
it("recognizes errors plural in a successful HTTP envelope", async () => {
const fetchMock: FetchImpl = async () =>
new Response(JSON.stringify({ errors: [{ code: 429, message: "quota exceeded" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
await expect(searchWithKagi("envelope error", { fetch: fetchMock }, fakeAuthStorage)).rejects.toThrow(
"Kagi API error (429): quota exceeded",
);
});
it("applies the configured timeout at the provider fetch boundary", async () => {
const timeoutSignal = new AbortController().signal;
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutSignal);
@@ -158,6 +186,39 @@ describe("Kagi search result parsing", () => {
expect(result.answer).toBeUndefined();
});
it("accepts documented result aliases and skips malformed items", async () => {
const fetchMock: FetchImpl = async () =>
new Response(
JSON.stringify({
data: {
search: [
null,
{ title: "Missing URL" },
{
href: "https://example.com/alias",
name: "Alias Result",
description: "Alias description",
},
],
related_search: { invalid: true },
},
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
const result = await searchWithKagi("aliases", { fetch: fetchMock }, fakeAuthStorage);
expect(result.sources).toEqual([
{
title: "Alias Result",
url: "https://example.com/alias",
snippet: "Alias description",
publishedDate: undefined,
},
]);
expect(result.relatedQuestions).toEqual([]);
});
it("parses direct_answer into the answer field", async () => {
const fetchMock: FetchImpl = async () =>
new Response(
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, setSystemTime, vi } from "bun:test";
import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
import type { AgentStorage } from "@oh-my-pi/pi-coding-agent/session/agent-storage";
import { searchWithParallel } from "@oh-my-pi/pi-coding-agent/web/parallel";
@@ -149,6 +149,29 @@ describe("Parallel web search", () => {
});
});
it("maps recency onto source_policy.after_date", async () => {
setSystemTime(new Date("2026-08-10T12:00:00Z"));
try {
const fetchMock = mockFetch({
search_id: "search-parallel-recency",
results: [],
warnings: null,
usage: null,
});
await searchParallel({ query: "recent api changes", recency: "week", fetch: fetchMock }, fakeAuthStorage);
expect(capturedRequestBody).toEqual({
objective: "recent api changes",
search_queries: ["recent api changes"],
mode: "fast",
excerpts: { max_chars_per_result: 10_000 },
source_policy: { after_date: "2026-08-03" },
});
} finally {
setSystemTime();
}
});
it("maps -site: and after: onto exclude_domains/after_date, keeping phrases and negation", async () => {
const fetchMock = mockFetch({
search_id: "search-parallel-4",
@@ -158,7 +181,11 @@ describe("Parallel web search", () => {
});
await searchParallel(
{ query: '"web api" -legacy -site:reddit.com/r/node after:2025-06-01', fetch: fetchMock },
{
query: '"web api" -legacy -site:reddit.com/r/node after:2025-06-01',
recency: "day",
fetch: fetchMock,
},
fakeAuthStorage,
);
expect(capturedRequestBody).toEqual({
@@ -178,4 +205,13 @@ describe("Parallel web search", () => {
message: "Parallel API error (503): upstream unavailable",
});
});
it("classifies malformed successful responses as Parallel errors", async () => {
const fetchMock: FetchImpl = () =>
Promise.resolve(new Response("{not-json", { status: 200, headers: { "Content-Type": "application/json" } }));
await expect(searchParallel({ query: "broken", fetch: fetchMock }, fakeAuthStorage)).rejects.toMatchObject({
provider: "parallel",
message: expect.stringContaining("Parallel search returned invalid JSON:"),
});
});
});
@@ -121,6 +121,53 @@ describe("SearXNG web search provider", () => {
expect(captured.url?.searchParams.get("language")).toBeNull();
});
it("sends configured category and safe-search filters and preserves instant answers", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "searxng-filters-"));
try {
await Bun.write(
path.join(agentDir, "config.yml"),
[
"searxng:",
" endpoint: https://searx.example.org",
" categories: news",
" safesearch: 2",
"",
].join("\n"),
);
await Settings.init({ agentDir });
const captured: { url?: URL } = {};
const fetchMock: FetchImpl = input => {
captured.url = new URL(input.toString());
return Promise.resolve(
new Response(
JSON.stringify({
results: [{ title: "r", url: "https://example.com", snippet: "Fallback snippet" }],
answers: [
" Forty-two ",
{ template: "answer/legacy.html", answer: "Legacy answer" },
{
template: "answer/translations.html",
translations: [{ text: "Hallo" }, { text: "Guten Tag" }],
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
};
const response = await searchSearXNG({ query: "filtered answers", fetch: fetchMock });
expect(captured.url?.searchParams.get("categories")).toBe("news");
expect(captured.url?.searchParams.get("safesearch")).toBe("2");
expect(response.answer).toBe("Forty-two\n\nLegacy answer\n\nHallo\nGuten Tag");
expect(response.sources[0]?.snippet).toBe("Fallback snippet");
} finally {
await removeWithRetries(agentDir);
}
});
it("reads Basic auth credentials from nested config.yml settings", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "searxng-settings-"));
try {
@@ -84,10 +84,16 @@ describe("TinyFish web search provider", () => {
});
expect(captured).toHaveLength(1);
expect(captured[0].searchParams.get("query")).toBe(
'"error handling" rust site:github.com -site:gitlab.com filetype:pdf',
);
expectTinyFishParams(captured[0], ["query", "num_results", "page"]);
expect(captured[0].searchParams.get("query")).toBe('"error handling" rust filetype:pdf');
expect(captured[0].searchParams.get("include_domains")).toBe("github.com");
expect(captured[0].searchParams.get("exclude_domains")).toBe("gitlab.com");
expectTinyFishParams(captured[0], [
"query",
"num_results",
"page",
"include_domains",
"exclude_domains",
]);
});
it("sends directive-free queries verbatim", async () => {
@@ -232,6 +238,43 @@ describe("TinyFish web search provider", () => {
expect(response.sources.at(-1)?.url).toBe("https://example.com/raw-page-11");
});
it("deduplicates and normalizes results across pages", async () => {
const captured: URL[] = [];
const firstPage = tinyFishResults("dedupe", 10);
firstPage[0] = {
title: " Primary title ",
url: " https://example.com/dedupe-0 ",
snippet: " spaced \n snippet ",
site_name: " Example ",
};
firstPage[1] = {
title: "Duplicate title",
url: "https://example.com/dedupe-0",
snippet: "duplicate snippet",
};
const fetchMock: FetchImpl = async input => {
const url = input instanceof URL ? input : new URL(typeof input === "string" ? input : input.url);
captured.push(url);
const page = Number(url.searchParams.get("page") ?? 0);
const results = page === 0 ? firstPage : tinyFishResults("dedupe", 1, 10);
return new Response(JSON.stringify(tinyFishPage(results, page, 11)), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const response = await searchTinyFish({ ...makeParams("dedupe fish"), limit: 10, fetch: fetchMock });
expect(captured.map(url => url.searchParams.get("page"))).toEqual(["0", "1"]);
expect(response.sources).toHaveLength(10);
expect(response.sources[0]).toMatchObject({
title: "Primary title",
url: "https://example.com/dedupe-0",
snippet: "spaced snippet",
});
expect(response.sources.at(-1)?.url).toBe("https://example.com/dedupe-10");
});
it("stops early for limit 20 when page 0 returns fewer than 10 raw results", async () => {
const captured: URL[] = [];
const fetchMock: FetchImpl = async input => {
@@ -802,6 +802,67 @@ describe("xAI web search provider", () => {
});
});
it("extracts offset snippets and raw sources from web_search_call output", async () => {
const answer = "Context before [cited source](https://example.com/cited) context after.";
const start = answer.indexOf("[cited source]");
const capture = captureFetch({
id: "resp_raw_sources",
output: [
{
type: "message",
content: [
{
type: "output_text",
text: answer,
annotations: [
{
type: "url_citation",
url: "https://example.com/cited",
title: "Cited result",
start_index: start,
end_index: start + "[cited source]".length,
},
],
},
],
},
{
type: "web_search_call",
action: {
sources: [
{ url: "https://example.com/raw", title: "Raw result" },
{ source_website_url: "https://example.com/fallback", caption: "Fallback result" },
],
},
results: [{ url: "https://example.com/cited", title: "Duplicate result" }],
},
],
});
const response = await searchXAI(makeParams(capture.fetchMock));
expect(response.answer).toBe(answer);
expect(response.sources).toEqual([
{
title: "Cited result",
url: "https://example.com/cited",
snippet: "Context before cited source context after.",
},
{ title: "Raw result", url: "https://example.com/raw", snippet: undefined },
{ title: "Fallback result", url: "https://example.com/fallback", snippet: undefined },
]);
});
it("rejects successful responses with no answer or sources", async () => {
const capture = captureFetch({ id: "resp_empty", output: [] });
await expect(searchXAI(makeParams(capture.fetchMock))).rejects.toMatchObject({
provider: "xai",
status: 502,
message: "xAI web_search returned no answer or sources",
});
});
it.each([
[401, "xai: 401 unauthorized"],
[402, "xai: 402 credits exhausted"],