mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-26 09:02:11 +03:00
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:
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
158
open-sse/handlers/search/xquikSearch.ts
Normal file
158
open-sse/handlers/search/xquikSearch.ts
Normal 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 };
|
||||
}
|
||||
@@ -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 ──────────────────────────────
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user