feat(search): add Xquik as an X search provider

Xquik needs a GET request with x-api-key auth and a tweets envelope
normalizer, neither of which the generic search fallback provides. Adds a
dedicated request builder and normalizer, cursor pagination passthrough,
result-based credit usage reporting, and a validateUrl probe so key
validation hits the no-charge credits endpoint.
This commit is contained in:
kriptoburak
2026-08-27 16:08:20 +07:00
parent 548e32aacf
commit f0a6d35818
10 changed files with 296 additions and 6 deletions

View File

@@ -347,6 +347,33 @@ function buildSearxngRequest(config, params) {
};
}
function buildXquikRequest(config, params) {
const apiKey = params.token;
if (!apiKey) throw new Error("Xquik requires an API key");
const queryType = getProviderSetting(params, "queryType");
if (queryType && !["Latest", "Top"].includes(queryType)) {
throw new Error("Xquik queryType must be Latest or Top");
}
const qp = new URLSearchParams({
q: params.query,
limit: String(params.maxResults),
});
const cursor = getProviderSetting(params, "cursor");
if (cursor) qp.set("cursor", cursor);
if (queryType) qp.set("queryType", queryType);
if (params.language) qp.set("language", params.language);
return {
url: `${resolveBaseUrl(config, params)}?${qp}`,
init: {
method: "GET",
headers: { Accept: "application/json", "x-api-key": apiKey },
},
};
}
// ── Dispatcher ──────────────────────────────────────────────────────────
const BUILDERS = {
@@ -360,6 +387,7 @@ const BUILDERS = {
"searchapi": buildSearchApiRequest,
"youcom": buildYouComRequest,
"searxng": buildSearxngRequest,
"xquik": buildXquikRequest,
};
/**

View File

@@ -111,6 +111,13 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType);
const results = normalized.results.slice(0, params.maxResults);
const duration = Date.now() - startTime;
const usage = {
queries_used: 1,
search_cost_usd: providerConfig.costPerQuery ?? null,
};
if (Number.isFinite(providerConfig.creditsPerResult)) {
usage.provider_credits_used = results.length * providerConfig.creditsPerResult;
}
return {
success: true,
@@ -119,7 +126,8 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
query: params.query,
results,
answer: null,
usage: { queries_used: 1, search_cost_usd: providerConfig.costPerQuery || 0 },
usage,
...(normalized.pagination ? { pagination: normalized.pagination } : {}),
metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults },
errors: []
}

View File

@@ -199,6 +199,47 @@ function normalizeSearxng(data, _query, _searchType) {
return { results, totalResults: results.length };
}
function normalizeXquik(data, _query, _searchType) {
const now = new Date().toISOString();
const items = Array.isArray(data.tweets) ? data.tweets : [];
const results = items.map((item, idx) => {
const username = typeof item?.author?.username === "string" ? item.author.username : "";
const authorName = typeof item?.author?.name === "string" ? item.author.name : "";
const tweetId = typeof item?.id === "string" ? item.id : String(item?.id || "");
const url = username && tweetId
? `https://x.com/${encodeURIComponent(username)}/status/${encodeURIComponent(tweetId)}`
: tweetId
? `https://x.com/i/web/status/${encodeURIComponent(tweetId)}`
: "";
const author = username ? `@${username}` : authorName || null;
const title = author ? `${author} on X` : "X post";
const imageUrl = Array.isArray(item?.media)
? item.media.find((media) => typeof media?.mediaUrl === "string")?.mediaUrl
: null;
return makeResult("xquik", {
title,
url,
snippet: typeof item?.text === "string" ? item.text : "",
published_at: typeof item?.createdAt === "string" ? item.createdAt : null,
author,
image_url: imageUrl || null,
source_type: "x_post",
full_text: typeof item?.text === "string" ? item.text : undefined,
text_format: "text",
}, idx, now);
});
const nextCursor = typeof data.next_cursor === "string" && data.next_cursor ? data.next_cursor : null;
return {
results,
totalResults: null,
pagination: {
has_more: data.has_next_page === true,
next_cursor: nextCursor,
},
};
}
const NORMALIZERS = {
"serper": normalizeSerper,
"brave-search": normalizeBrave,
@@ -210,11 +251,12 @@ const NORMALIZERS = {
"searchapi": normalizeSearchApi,
"youcom": normalizeYouCom,
"searxng": normalizeSearxng,
"xquik": normalizeXquik,
};
/**
* Dispatch to the appropriate normalizer based on providerId.
* @returns {{results: Array, totalResults: number|null}}
* @returns {{results: Array, totalResults: number|null, pagination?: object}}
*/
export function normalizeSearchResponse(providerId, data, query, searchType) {
const fn = NORMALIZERS[providerId];

View File

@@ -121,6 +121,7 @@ import p118 from "./selfhosted-tts.js";
import p119 from "./selfhosted-embedding.js";
import p120 from "./fish-audio.js";
import p121 from "./alitp-intl.js";
import p122 from "./xquik.js";
export default [
p0,
@@ -243,4 +244,5 @@ export default [
p119,
p120,
p121,
p122,
];

View File

@@ -0,0 +1,35 @@
export default {
id: "xquik",
alias: "xquik",
display: {
name: "Xquik",
icon: "tag",
color: "#5C3327",
textIcon: "XQ",
website: "https://docs.xquik.com/api-reference/x/search-tweets",
notice: {
apiKeyUrl: "https://xquik.com",
text: "Searches public X posts. Billing uses 1 Xquik credit per returned post."
}
},
category: "apikey",
authType: "apikey",
serviceKinds: [
"webSearch"
],
searchConfig: {
baseUrl: "https://xquik.com/api/v1/x/tweets/search",
validateUrl: "https://xquik.com/api/v1/credits",
method: "GET",
authType: "apikey",
authHeader: "x-api-key",
searchTypes: [
"x"
],
defaultMaxResults: 5,
maxMaxResults: 100,
timeoutMs: 10000,
cacheTTLMs: 60000,
creditsPerResult: 1
}
};

BIN
public/providers/xquik.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,6 +1,6 @@
---
name: 9router-web-search
description: Web search via 9Router /v1/search using Tavily / Exa / Brave / Serper / SearXNG / Google PSE / Linkup / SearchAPI / You.com / Perplexity. Use when the user wants to search the web, look up information, find articles, or query a search engine.
description: Web and X search via 9Router /v1/search using Tavily / Exa / Brave / Serper / SearXNG / Google PSE / Linkup / SearchAPI / You.com / Perplexity / Xquik. Use when the user wants to search the web, find articles, or search public X posts.
---
# 9Router — Web Search
@@ -26,7 +26,7 @@ IDs end in `/search` (e.g. `tavily/search`). Combos (`owned_by:"combo"`) chain p
| `model` (or `provider`) | yes | from `/v1/models/web` (e.g. `tavily` or `brave`) |
| `query` | yes | search query |
| `max_results` | no | default 5 |
| `search_type` | no | `web` (default) / `news` |
| `search_type` | no | `web` (default) / `news` / `x` for Xquik |
| `country`, `language`, `time_range`, `domain_filter` | no | provider-dependent |
## Examples
@@ -49,6 +49,26 @@ const r = await fetch(`${process.env.NINEROUTER_URL}/v1/search`, {
console.log(await r.json());
```
X search with Xquik:
```bash
curl -X POST $NINEROUTER_URL/v1/search \
-H "Authorization: Bearer $NINEROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"xquik","query":"from:github release","max_results":10,"provider_options":{"queryType":"Latest"}}'
```
Add the Xquik API key in 9Router's provider settings. Xquik charges 1 credit per returned post. Continue a search by passing `pagination.next_cursor` as `provider_options.cursor`.
Xquik responses include provider pagination and credit usage:
```json
{
"pagination": { "has_more": true, "next_cursor": "cursor-2" },
"usage": { "queries_used": 1, "search_cost_usd": null, "provider_credits_used": 10 }
}
```
## Response shape
```json
@@ -87,5 +107,6 @@ All accept `query` + `max_results`. Optional fields vary:
| `searchapi` | country, language, pagination | — |
| `youcom` | country, language, time_range, domain_filter, full_page | — |
| `searxng` | language, time_range | Self-hosted, **noAuth** |
| `xquik` | X/Twitter search operators, language, cursor pagination | `queryType: Latest/Top`, `cursor` (options) |
Provider IS the model — `"provider":"tavily" ≡ "model":"tavily"`.

View File

@@ -20,7 +20,7 @@ async function probeWebProvider(provider, apiKey) {
if (!cfg) return null;
if (cfg.authType === "none") return true; // no-auth (e.g. searxng)
let url = cfg.baseUrl;
let url = cfg.validateUrl || cfg.baseUrl;
const headers = { "Content-Type": "application/json" };
let body;

View File

@@ -56,7 +56,7 @@ export const SKILLS = [
{
id: "9router-web-search",
name: "Web Search",
description: "Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com.",
description: "Web and X search via Tavily / Exa / Brave / Serper / SearXNG / Google PSE / You.com / Xquik.",
endpoint: "/v1/search",
icon: "search",
},

View File

@@ -0,0 +1,154 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import REGISTRY from "../../open-sse/providers/registry/index.js";
import { buildSearchRequest } from "../../open-sse/handlers/search/callers.js";
import { handleSearchCore } from "../../open-sse/handlers/search/index.js";
import { normalizeSearchResponse } from "../../open-sse/handlers/search/normalizers.js";
import { AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers.js";
const CONFIG = {
id: "xquik",
baseUrl: "https://xquik.com/api/v1/x/tweets/search",
method: "GET",
authType: "apikey",
searchTypes: ["x"],
defaultMaxResults: 5,
maxMaxResults: 100,
creditsPerResult: 1,
};
const PARAMS = {
query: "from:github release notes",
searchType: "x",
maxResults: 10,
token: "xq_test_key",
language: "en",
providerOptions: { queryType: "Latest", cursor: "next page" },
};
const RESPONSE = {
tweets: [
{
id: "1234567890",
text: "Release notes are live.",
createdAt: "2026-08-25T12:00:00Z",
author: { username: "github", name: "GitHub" },
media: [{ mediaUrl: "https://pbs.twimg.com/media/example.jpg", type: "photo" }],
},
],
has_next_page: true,
next_cursor: "cursor-2",
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Xquik search provider", () => {
it("registers a dedicated X search provider with no-charge key validation", () => {
const entry = REGISTRY.find((candidate) => candidate.id === "xquik");
expect(entry).toMatchObject({
category: "apikey",
serviceKinds: ["webSearch"],
searchConfig: {
authHeader: "x-api-key",
validateUrl: "https://xquik.com/api/v1/credits",
searchTypes: ["x"],
creditsPerResult: 1,
},
});
expect(AI_PROVIDERS.xquik?.searchConfig).toEqual(entry.searchConfig);
expect(getProvidersByKind("webSearch").map((provider) => provider.id)).toContain("xquik");
});
it("builds the documented GET request without putting the key in the URL", () => {
const request = buildSearchRequest(CONFIG, PARAMS);
const url = new URL(request.url);
expect(url.origin + url.pathname).toBe("https://xquik.com/api/v1/x/tweets/search");
expect(Object.fromEntries(url.searchParams)).toEqual({
q: "from:github release notes",
limit: "10",
cursor: "next page",
queryType: "Latest",
language: "en",
});
expect(url.search).not.toContain("xq_test_key");
expect(request.init).toEqual({
method: "GET",
headers: { Accept: "application/json", "x-api-key": "xq_test_key" },
});
});
it("rejects unsupported query types before contacting Xquik", () => {
expect(() => buildSearchRequest(CONFIG, {
...PARAMS,
providerOptions: { queryType: "Popular" },
})).toThrow("Xquik queryType must be Latest or Top");
});
it("normalizes posts and preserves cursor pagination", () => {
const normalized = normalizeSearchResponse("xquik", RESPONSE, PARAMS.query, "x");
expect(normalized.totalResults).toBeNull();
expect(normalized.pagination).toEqual({ has_more: true, next_cursor: "cursor-2" });
expect(normalized.results).toHaveLength(1);
expect(normalized.results[0]).toMatchObject({
title: "@github on X",
url: "https://x.com/github/status/1234567890",
display_url: "x.com/github/status/1234567890",
snippet: "Release notes are live.",
published_at: "2026-08-25T12:00:00Z",
metadata: {
author: "@github",
source_type: "x_post",
image_url: "https://pbs.twimg.com/media/example.jpg",
},
citation: { provider: "xquik", rank: 1 },
});
expect(normalized.results[0].content).toEqual({
format: "text",
text: "Release notes are live.",
length: 23,
});
});
it("uses the stable status URL when author data is unavailable", () => {
const normalized = normalizeSearchResponse("xquik", {
tweets: [{ id: "9876543210", text: "Author data is unavailable." }],
has_next_page: false,
next_cursor: "",
}, PARAMS.query, "x");
expect(normalized.results[0]).toMatchObject({
title: "X post",
url: "https://x.com/i/web/status/9876543210",
metadata: { author: null, source_type: "x_post" },
});
expect(normalized.pagination).toEqual({ has_more: false, next_cursor: null });
});
it("reports Xquik credits without claiming an unknown USD cost", async () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify(RESPONSE), {
status: 200,
headers: { "Content-Type": "application/json" },
})));
const result = await handleSearchCore({
body: { query: PARAMS.query, max_results: 10, provider_options: PARAMS.providerOptions },
provider: { id: "xquik" },
providerConfig: CONFIG,
credentials: { apiKey: "xq_test_key" },
});
const payload = await result.response.json();
expect(result.success).toBe(true);
expect(payload.usage).toEqual({
queries_used: 1,
search_cost_usd: null,
provider_credits_used: 1,
});
expect(payload.pagination).toEqual({ has_more: true, next_cursor: "cursor-2" });
});
});