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:
@@ -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 ──────────────────────────────────────────────────────────
|
// ── Dispatcher ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const BUILDERS = {
|
const BUILDERS = {
|
||||||
@@ -360,6 +387,7 @@ const BUILDERS = {
|
|||||||
"searchapi": buildSearchApiRequest,
|
"searchapi": buildSearchApiRequest,
|
||||||
"youcom": buildYouComRequest,
|
"youcom": buildYouComRequest,
|
||||||
"searxng": buildSearxngRequest,
|
"searxng": buildSearxngRequest,
|
||||||
|
"xquik": buildXquikRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
|||||||
const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType);
|
const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType);
|
||||||
const results = normalized.results.slice(0, params.maxResults);
|
const results = normalized.results.slice(0, params.maxResults);
|
||||||
const duration = Date.now() - startTime;
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -119,7 +126,8 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
|||||||
query: params.query,
|
query: params.query,
|
||||||
results,
|
results,
|
||||||
answer: null,
|
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 },
|
metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults },
|
||||||
errors: []
|
errors: []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,47 @@ function normalizeSearxng(data, _query, _searchType) {
|
|||||||
return { results, totalResults: results.length };
|
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 = {
|
const NORMALIZERS = {
|
||||||
"serper": normalizeSerper,
|
"serper": normalizeSerper,
|
||||||
"brave-search": normalizeBrave,
|
"brave-search": normalizeBrave,
|
||||||
@@ -210,11 +251,12 @@ const NORMALIZERS = {
|
|||||||
"searchapi": normalizeSearchApi,
|
"searchapi": normalizeSearchApi,
|
||||||
"youcom": normalizeYouCom,
|
"youcom": normalizeYouCom,
|
||||||
"searxng": normalizeSearxng,
|
"searxng": normalizeSearxng,
|
||||||
|
"xquik": normalizeXquik,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch to the appropriate normalizer based on providerId.
|
* 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) {
|
export function normalizeSearchResponse(providerId, data, query, searchType) {
|
||||||
const fn = NORMALIZERS[providerId];
|
const fn = NORMALIZERS[providerId];
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ import p118 from "./selfhosted-tts.js";
|
|||||||
import p119 from "./selfhosted-embedding.js";
|
import p119 from "./selfhosted-embedding.js";
|
||||||
import p120 from "./fish-audio.js";
|
import p120 from "./fish-audio.js";
|
||||||
import p121 from "./alitp-intl.js";
|
import p121 from "./alitp-intl.js";
|
||||||
|
import p122 from "./xquik.js";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
p0,
|
p0,
|
||||||
@@ -243,4 +244,5 @@ export default [
|
|||||||
p119,
|
p119,
|
||||||
p120,
|
p120,
|
||||||
p121,
|
p121,
|
||||||
|
p122,
|
||||||
];
|
];
|
||||||
|
|||||||
35
open-sse/providers/registry/xquik.js
Normal file
35
open-sse/providers/registry/xquik.js
Normal 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
BIN
public/providers/xquik.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.4 KiB |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: 9router-web-search
|
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
|
# 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`) |
|
| `model` (or `provider`) | yes | from `/v1/models/web` (e.g. `tavily` or `brave`) |
|
||||||
| `query` | yes | search query |
|
| `query` | yes | search query |
|
||||||
| `max_results` | no | default 5 |
|
| `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 |
|
| `country`, `language`, `time_range`, `domain_filter` | no | provider-dependent |
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
@@ -49,6 +49,26 @@ const r = await fetch(`${process.env.NINEROUTER_URL}/v1/search`, {
|
|||||||
console.log(await r.json());
|
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
|
## Response shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -87,5 +107,6 @@ All accept `query` + `max_results`. Optional fields vary:
|
|||||||
| `searchapi` | country, language, pagination | — |
|
| `searchapi` | country, language, pagination | — |
|
||||||
| `youcom` | country, language, time_range, domain_filter, full_page | — |
|
| `youcom` | country, language, time_range, domain_filter, full_page | — |
|
||||||
| `searxng` | language, time_range | Self-hosted, **noAuth** |
|
| `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"`.
|
Provider IS the model — `"provider":"tavily" ≡ "model":"tavily"`.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ async function probeWebProvider(provider, apiKey) {
|
|||||||
if (!cfg) return null;
|
if (!cfg) return null;
|
||||||
if (cfg.authType === "none") return true; // no-auth (e.g. searxng)
|
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" };
|
const headers = { "Content-Type": "application/json" };
|
||||||
let body;
|
let body;
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const SKILLS = [
|
|||||||
{
|
{
|
||||||
id: "9router-web-search",
|
id: "9router-web-search",
|
||||||
name: "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",
|
endpoint: "/v1/search",
|
||||||
icon: "search",
|
icon: "search",
|
||||||
},
|
},
|
||||||
|
|||||||
154
tests/unit/xquik-search-provider.test.js
Normal file
154
tests/unit/xquik-search-provider.test.js
Normal 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user