diff --git a/open-sse/handlers/search/callers.js b/open-sse/handlers/search/callers.js index 3c02828e..e454d385 100644 --- a/open-sse/handlers/search/callers.js +++ b/open-sse/handlers/search/callers.js @@ -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, }; /** diff --git a/open-sse/handlers/search/index.js b/open-sse/handlers/search/index.js index f5815471..662b293e 100644 --- a/open-sse/handlers/search/index.js +++ b/open-sse/handlers/search/index.js @@ -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: [] } diff --git a/open-sse/handlers/search/normalizers.js b/open-sse/handlers/search/normalizers.js index 898b271f..9d7233f4 100644 --- a/open-sse/handlers/search/normalizers.js +++ b/open-sse/handlers/search/normalizers.js @@ -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]; diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 6482293a..4570aefb 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -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, ]; diff --git a/open-sse/providers/registry/xquik.js b/open-sse/providers/registry/xquik.js new file mode 100644 index 00000000..bdac9bab --- /dev/null +++ b/open-sse/providers/registry/xquik.js @@ -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 + } +}; diff --git a/public/providers/xquik.png b/public/providers/xquik.png new file mode 100644 index 00000000..8f359751 Binary files /dev/null and b/public/providers/xquik.png differ diff --git a/skills/9router-web-search/SKILL.md b/skills/9router-web-search/SKILL.md index ebd3003d..afb0adea 100644 --- a/skills/9router-web-search/SKILL.md +++ b/skills/9router-web-search/SKILL.md @@ -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"`. diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js index e9f8f400..7cdabc5e 100644 --- a/src/app/api/providers/validate/route.js +++ b/src/app/api/providers/validate/route.js @@ -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; diff --git a/src/shared/constants/skills.js b/src/shared/constants/skills.js index 1f758e32..bd305dcc 100644 --- a/src/shared/constants/skills.js +++ b/src/shared/constants/skills.js @@ -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", }, diff --git a/tests/unit/xquik-search-provider.test.js b/tests/unit/xquik-search-provider.test.js new file mode 100644 index 00000000..3f48ef4f --- /dev/null +++ b/tests/unit/xquik-search-provider.test.js @@ -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" }); + }); +});