feat(search): add Xquik X search provider (#11370)

Merged into release/v3.8.51 via batch validation: xquik provider suites green on the combined tree (193 node:test assertions incl. your 7 new cases), check:provider-consistency OK (353 canonical providers), static gates green. Well-scoped fallbackOnly X-provider with clean citation building — thanks @kriptoburak!
This commit is contained in:
Burak Bayır
2026-08-25 07:39:39 +03:00
committed by GitHub
parent 14ca809924
commit a0ceccc6f0
70 changed files with 893 additions and 248 deletions

View File

@@ -330,6 +330,25 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
timeoutMs: 60_000,
cacheTTLMs: 5 * 60 * 1000,
},
// Direct X API search through Xquik. Keep it fallback-only so the existing
// SuperGrok provider remains the default for search_type "x".
"xquik-search": {
id: "xquik-search",
name: "Xquik X Search",
baseUrl: "https://xquik.com/api/v1/x/tweets/search",
method: "GET",
authType: "apikey",
authHeader: "x-api-key",
costPerQuery: 0.00075,
freeMonthlyQuota: 0,
searchTypes: ["x"],
defaultMaxResults: 5,
maxMaxResults: 20,
timeoutMs: 15_000,
cacheTTLMs: 5 * 60 * 1000,
fallbackOnly: true,
},
};
/**
@@ -377,6 +396,8 @@ export const SEARCH_PROVIDER_ALIASES: Record<string, string> = {
c7: "context7",
x_search: "x-search",
x: "x-search",
xquik: "xquik-search",
xquik_search: "xquik-search",
};
export function resolveSearchProviderId(providerId: string): string {

View File

@@ -8,6 +8,7 @@ import { randomUUID } from "crypto";
* firecrawl, google-pse-search, linkup-search, searchapi-search,
* youcom-search, searxng-search, ollama-search, zai-search, jina-search,
* duckduckgo-free, x-search (Grok / SuperGrok X Search — explicit or search_type "x")
* and xquik-search (direct X API search — explicit or credentialed fallback)
*
* Request format:
* {
@@ -28,6 +29,7 @@ import * as fcSearch from "./search/firecrawlSearch.ts";
import { type FirecrawlSearchEnvelope } from "./search/firecrawlSearch.ts";
import { buildJinaSearchRequest, extractJinaSearchItems } from "./search/jinaSearch.ts";
import * as xSearch from "./search/xSearch.ts";
import * as xquikSearch from "./search/xquikSearch.ts";
import { freeWebSearch } from "../services/freeWebSearch.ts";
import { saveCallLog } from "@/lib/usageDb";
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
@@ -714,6 +716,7 @@ const requestBuilders: Record<string, SearchRequestBuilder> = {
"ollama-search": buildOllamaRequest,
"jina-search": buildJinaSearchRequest,
"x-search": xSearch.buildXSearchRequest,
"xquik-search": xquikSearch.buildXquikSearchRequest,
};
function buildRequest(
@@ -1290,6 +1293,7 @@ const responseNormalizers: Record<string, SearchResponseNormalizer> = {
"ollama-search": normalizeOllamaResponse,
"jina-search": normalizeJinaSearchResponse,
"x-search": normalizeXSearchResponse,
"xquik-search": (data) => xquikSearch.normalizeXquikSearchResponse(data, makeResult),
};
function normalizeResponse(

View File

@@ -0,0 +1,158 @@
/** Xquik-backed X search for the unified search gateway. */
import { z } from "zod";
import type { SearchProviderConfig } from "../../config/searchRegistry.ts";
import type { SearchResult } from "../search.ts";
export const XQUIK_SEARCH_PROVIDER_ID = "xquik-search";
export interface XquikSearchParams {
query: string;
maxResults: number;
token?: string;
timeRange?: string;
providerOptions?: Record<string, unknown>;
providerSpecificData?: Record<string, unknown>;
}
export interface XquikSearchHit {
title: string;
url: string;
snippet: string;
author?: string;
publishedAt?: string;
}
type MakeResult = (
providerId: string,
item: {
title?: string;
url?: string;
snippet?: string;
published_at?: string;
author?: string;
source_type?: string;
},
index: number,
now: string
) => SearchResult;
const X_HANDLE_RE = /^[A-Za-z0-9_]{1,15}$/;
const TWEET_ID_RE = /^\d+$/;
const XquikTweetSchema = z
.object({
id: z.string().regex(TWEET_ID_RE),
text: z.string(),
createdAt: z.string().optional(),
author: z
.object({
username: z.string().regex(X_HANDLE_RE),
name: z.string().optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const XquikSearchEnvelopeSchema = z
.object({
tweets: z.array(z.unknown()).default([]),
})
.passthrough();
function getProviderSettingString(
params: Pick<XquikSearchParams, "providerOptions" | "providerSpecificData">,
key: string
): string | undefined {
const option = params.providerOptions?.[key];
if (typeof option === "string" && option.trim()) return option.trim();
const configured = params.providerSpecificData?.[key];
if (typeof configured === "string" && configured.trim()) return configured.trim();
return undefined;
}
function sinceTimeForRange(timeRange: string | undefined, now = Date.now()): string | undefined {
const hour = 60 * 60 * 1000;
const durations: Record<string, number> = {
hour,
day: 24 * hour,
week: 7 * 24 * hour,
month: 30 * 24 * hour,
year: 365 * 24 * hour,
};
const duration = timeRange ? durations[timeRange] : undefined;
return duration ? new Date(now - duration).toISOString() : undefined;
}
export function buildXquikSearchRequest(
config: SearchProviderConfig,
params: XquikSearchParams
): { url: string; init: RequestInit } {
const queryType = getProviderSettingString(params, "queryType") === "Top" ? "Top" : "Latest";
const query = new URLSearchParams({
q: params.query,
queryType,
limit: String(params.maxResults),
});
const sinceTime = sinceTimeForRange(params.timeRange);
if (sinceTime) query.set("sinceTime", sinceTime);
return {
url: `${config.baseUrl.replace(/\/+$/, "")}?${query}`,
init: {
method: "GET",
headers: {
Accept: "application/json",
...(params.token ? { "x-api-key": params.token } : {}),
},
},
};
}
export function extractXquikSearchHits(data: unknown, maxResults: number): XquikSearchHit[] {
const envelope = XquikSearchEnvelopeSchema.safeParse(data);
if (!envelope.success) return [];
const hits: XquikSearchHit[] = [];
for (const value of envelope.data.tweets) {
const parsed = XquikTweetSchema.safeParse(value);
if (!parsed.success) continue;
const tweet = parsed.data;
const author = tweet.author?.username;
hits.push({
title: author ? `@${author}` : "X post",
url: author
? `https://x.com/${author}/status/${tweet.id}`
: `https://x.com/i/status/${tweet.id}`,
snippet: tweet.text,
author,
publishedAt: tweet.createdAt,
});
if (hits.length >= maxResults) break;
}
return hits;
}
export function normalizeXquikSearchResponse(
data: unknown,
makeResult: MakeResult
): { results: SearchResult[]; totalResults: number } {
const now = new Date().toISOString();
const results = extractXquikSearchHits(data, 20).map((hit, index) =>
makeResult(
XQUIK_SEARCH_PROVIDER_ID,
{
title: hit.title,
url: hit.url,
snippet: hit.snippet,
published_at: hit.publishedAt,
author: hit.author,
source_type: "x",
},
index,
now
)
);
return { results, totalResults: results.length };
}

View File

@@ -358,6 +358,38 @@ describe("omniroute_x_search handler (via MCP dispatch)", () => {
expect(body.search_type).toBe("x");
expect(body.provider).toBe("x-search");
});
it("should route an explicit Xquik search through xquik-search", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
id: "xs2",
provider: "xquik-search",
query: "agents sdk",
results: [
{
title: "@openai",
url: "https://x.com/openai/status/1912345678901234567",
snippet: "Agents SDK update",
position: 1,
},
],
cached: false,
usage: { queries_used: 1, search_cost_usd: 0.00015 },
}),
});
const result = await client.callTool({
name: "omniroute_x_search",
arguments: { query: "agents sdk", max_results: 5, provider: "xquik-search" },
});
expect(result.isError).toBeFalsy();
const [, options] = mockFetch.mock.calls[0];
const body = JSON.parse(options.body as string);
expect(body.search_type).toBe("x");
expect(body.provider).toBe("xquik-search");
});
});
// ── omniroute_get_health: handler dispatch tests ──────────────────────────────

View File

@@ -531,12 +531,17 @@ export const xSearchInput = z.object({
.max(20)
.default(5)
.describe("Maximum number of X results to return"),
provider: z
.enum(["x-search", "xquik-search"])
.optional()
.default("x-search")
.describe("X search backend: x-search uses xAI/SuperGrok; xquik-search uses Xquik"),
});
export const xSearchTool: McpToolDefinition<typeof xSearchInput, typeof webSearchOutput> = {
name: "omniroute_x_search",
description:
"Search X (Twitter) through OmniRoute using SuperGrok / xAI server-side x_search. Requires a connected xai-oauth (SuperGrok) or xAI API key. This is Grok X Search, not web search and not the X Developer Platform MCP.",
"Search X (Twitter) through OmniRoute. Uses SuperGrok / xAI server-side x_search by default, or Xquik when provider is xquik-search. Requires credentials for the selected backend. This is not web search.",
inputSchema: xSearchInput,
outputSchema: webSearchOutput,
scopes: ["execute:search"],

View File

@@ -666,7 +666,11 @@ async function handleWebSearch(args: {
}
}
async function handleXSearch(args: { query: string; max_results?: number }) {
async function handleXSearch(args: {
query: string;
max_results?: number;
provider?: "x-search" | "xquik-search";
}) {
const start = Date.now();
try {
const result = await omniRouteFetch("/v1/search", {
@@ -675,7 +679,7 @@ async function handleXSearch(args: { query: string; max_results?: number }) {
query: args.query,
max_results: args.max_results ?? 5,
search_type: "x",
provider: "x-search",
provider: args.provider ?? "x-search",
}),
signal: AbortSignal.timeout(120000),
});