Files
9router/open-sse/handlers/search/chatSearch.js
decolua 8f81363675 Enhance token refresh functionality across multiple executors
- Updated refreshCredentials methods in various executors (Antigravity, Base, Default, Github, Kiro) to accept optional proxyOptions for improved proxy handling.
- Modified token refresh logic to utilize proxy-aware fetch for better network management.
- Enhanced usage retrieval functions to support proxy options, ensuring seamless integration with proxy configurations.
- Updated ModelSelectModal and ProviderInfoCard components to incorporate kind filtering for improved user experience in model selection.
- Added validation for API keys in the provider validation route, including support for webSearch/webFetch providers.
2026-04-28 17:28:57 +07:00

410 lines
12 KiB
JavaScript

/**
* Wrap chat-completions endpoints (with built-in web search) into the unified
* /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity.
*/
const REQUEST_TIMEOUT_MS = 15000;
const DEFAULT_MAX_RESULTS = 10;
/**
* Normalize a citation entry into the unified result shape.
* @param {{url:string, title?:string, snippet?:string}} c
* @param {number} index
* @param {string} provider
* @param {string} retrievedAt
*/
function toResult(c, index, provider, retrievedAt) {
return {
title: c.title || "",
url: c.url,
snippet: c.snippet || "",
position: index + 1,
score: null,
published_at: null,
favicon_url: null,
content: null,
metadata: {},
citation: { provider, retrieved_at: retrievedAt, rank: index + 1 },
provider_raw: null
};
}
/** Coerce a citation that might be a raw URL string or an object. */
function normalizeCitation(c) {
if (!c) return null;
if (typeof c === "string") return { url: c };
if (typeof c === "object" && c.url) return c;
return null;
}
/**
* Provider-specific configuration map. All providers must implement:
* { endpoint, defaultModel, buildBody, buildHeaders, extractAnswer }
*/
const CHAT_SEARCH_CONFIG = {
gemini: {
endpoint: (model) =>
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
defaultModel: "gemini-2.5-flash",
buildBody: (query) => ({
contents: [{ role: "user", parts: [{ text: query }] }],
tools: [{ google_search: {} }]
}),
buildHeaders: (token) => ({
"Content-Type": "application/json",
"x-goog-api-key": token
}),
extractAnswer: (data) => {
const candidate = data?.candidates?.[0];
const parts = candidate?.content?.parts || [];
const text = parts.map((p) => p?.text || "").filter(Boolean).join("");
const chunks = candidate?.groundingMetadata?.groundingChunks || [];
const citations = chunks
.map((ch) => ch?.web)
.filter(Boolean)
.map((w) => ({ url: w.uri || w.url, title: w.title || "" }))
.filter((c) => c.url);
const tokens = data?.usageMetadata?.totalTokenCount || 0;
return { text, citations, tokens };
}
},
openai: {
endpoint: () => "https://api.openai.com/v1/chat/completions",
defaultModel: "gpt-4o-mini",
buildBody: (query, model) => {
const body = {
model,
messages: [{ role: "user", content: query }]
};
// Non-search-preview models need explicit web_search tool
if (!/search/i.test(model)) {
body.tools = [{ type: "web_search" }];
}
return body;
},
buildHeaders: (token) => ({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
}),
extractAnswer: (data) => {
const msg = data?.choices?.[0]?.message || {};
const text = msg.content || "";
const annotations = Array.isArray(msg.annotations) ? msg.annotations : [];
const fromAnn = annotations
.map((a) => a?.url_citation)
.filter(Boolean)
.map((u) => ({ url: u.url, title: u.title || "" }));
const fromTop = Array.isArray(data?.citations)
? data.citations.map(normalizeCitation).filter(Boolean)
: [];
const citations = fromAnn.length ? fromAnn : fromTop;
const tokens = data?.usage?.total_tokens || 0;
return { text, citations, tokens };
}
},
xai: {
endpoint: () => "https://api.x.ai/v1/responses",
defaultModel: "grok-4.20-reasoning",
buildBody: (query, model) => ({
model,
input: [{ role: "user", content: query }],
tools: [{ type: "web_search" }]
}),
buildHeaders: (token) => ({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
}),
extractAnswer: (data) => {
// /v1/responses returns output[] array of message/tool blocks
const output = Array.isArray(data?.output) ? data.output : [];
let text = "";
const citations = [];
for (const item of output) {
const parts = Array.isArray(item?.content) ? item.content : [];
for (const p of parts) {
if (typeof p?.text === "string") text += p.text;
const anns = Array.isArray(p?.annotations) ? p.annotations : [];
for (const a of anns) {
const c = normalizeCitation(a?.url ? a : a?.url_citation);
if (c) citations.push(c);
}
}
}
// Fallback: top-level citations array (some response variants)
if (!citations.length && Array.isArray(data?.citations)) {
for (const c of data.citations) {
const n = normalizeCitation(c);
if (n) citations.push(n);
}
}
const tokens = data?.usage?.total_tokens || 0;
return { text, citations, tokens };
}
},
kimi: {
endpoint: () => "https://api.moonshot.cn/v1/chat/completions",
defaultModel: "kimi-k2.5",
buildBody: (query, model) => ({
model,
messages: [{ role: "user", content: query }],
tools: [
{ type: "builtin_function", function: { name: "$web_search" } }
]
}),
buildHeaders: (token) => ({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
}),
extractAnswer: (data) => {
const msg = data?.choices?.[0]?.message || {};
const text = msg.content || "";
const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
const citations = [];
for (const call of calls) {
const argStr = call?.function?.arguments;
if (!argStr) continue;
let parsed;
try {
parsed = typeof argStr === "string" ? JSON.parse(argStr) : argStr;
} catch {
continue;
}
const items =
parsed?.search_results ||
parsed?.results ||
parsed?.references ||
[];
if (Array.isArray(items)) {
for (const it of items) {
const url = it?.url || it?.link;
if (!url) continue;
citations.push({
url,
title: it.title || "",
snippet: it.snippet || it.summary || ""
});
}
}
}
const tokens = data?.usage?.total_tokens || 0;
return { text, citations, tokens };
}
},
minimax: {
endpoint: () => "https://api.minimaxi.com/v1/text/chatcompletion_v2",
defaultModel: "MiniMax-M2.7",
buildBody: (query, model) => ({
model,
messages: [{ role: "user", content: query }],
tools: [{ type: "web_search" }]
}),
buildHeaders: (token) => ({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
}),
extractAnswer: (data) => {
const msg = data?.choices?.[0]?.message || {};
const text = msg.content || "";
const citations = [];
const direct = Array.isArray(data?.web_search_results)
? data.web_search_results
: [];
for (const it of direct) {
const url = it?.url || it?.link;
if (url) {
citations.push({
url,
title: it.title || "",
snippet: it.snippet || it.summary || ""
});
}
}
if (!citations.length) {
const calls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
for (const call of calls) {
const argStr = call?.function?.arguments;
if (!argStr) continue;
let parsed;
try {
parsed = typeof argStr === "string" ? JSON.parse(argStr) : argStr;
} catch {
continue;
}
const items = parsed?.results || parsed?.search_results || [];
if (Array.isArray(items)) {
for (const it of items) {
const url = it?.url || it?.link;
if (!url) continue;
citations.push({
url,
title: it.title || "",
snippet: it.snippet || ""
});
}
}
}
}
const tokens = data?.usage?.total_tokens || 0;
return { text, citations, tokens };
}
},
perplexity: {
endpoint: () => "https://api.perplexity.ai/chat/completions",
defaultModel: "sonar",
buildBody: (query, model) => ({
model,
messages: [{ role: "user", content: query }]
}),
buildHeaders: (token) => ({
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
}),
extractAnswer: (data) => {
const msg = data?.choices?.[0]?.message || {};
const text = msg.content || "";
const raw = data?.citations || [];
const citations = Array.isArray(raw)
? raw.map(normalizeCitation).filter(Boolean)
: [];
const tokens = data?.usage?.total_tokens || 0;
return { text, citations, tokens };
}
}
};
/**
* Execute a chat-search request against the chosen provider.
* @param {object} params
* @param {string} params.provider
* @param {string} params.query
* @param {number} [params.maxResults]
* @param {string} [params.model]
* @param {{apiKey?:string, accessToken?:string}} params.credentials
* @param {{info?:Function, warn?:Function, error?:Function}} [params.log]
* @returns {Promise<{success:boolean, status?:number, error?:string, data?:object}>}
*/
export async function handleChatSearch({
provider,
query,
maxResults,
model,
credentials,
log
}) {
const startTime = Date.now();
const cfg = CHAT_SEARCH_CONFIG[provider];
if (!cfg) {
return {
success: false,
status: 400,
error: `Unsupported chat-search provider: ${provider}`
};
}
if (!query || typeof query !== "string") {
return { success: false, status: 400, error: "Missing query" };
}
const token = credentials?.apiKey || credentials?.accessToken;
if (!token) {
return {
success: false,
status: 401,
error: "Missing credentials (apiKey or accessToken)"
};
}
const limit =
Number.isFinite(maxResults) && maxResults > 0
? Math.floor(maxResults)
: DEFAULT_MAX_RESULTS;
const useModel = model || cfg.defaultModel;
const url = cfg.endpoint(useModel);
const body = cfg.buildBody(query, useModel);
const headers = cfg.buildHeaders(token);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let upstreamStart = Date.now();
let resp;
try {
resp = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: controller.signal
});
} catch (err) {
clearTimeout(timer);
if (err?.name === "AbortError") {
log?.warn?.(`[chatSearch] timeout provider=${provider}`);
return { success: false, status: 504, error: "Upstream timeout" };
}
log?.error?.(`[chatSearch] network error provider=${provider}: ${err?.message}`);
return {
success: false,
status: 502,
error: `Network error: ${err?.message || "unknown"}`
};
}
clearTimeout(timer);
const upstreamLatency = Date.now() - upstreamStart;
let data;
try {
data = await resp.json();
} catch {
return {
success: false,
status: 502,
error: `Invalid upstream response (status ${resp.status})`
};
}
if (!resp.ok) {
const errMsg =
data?.error?.message ||
data?.error ||
data?.message ||
`Upstream HTTP ${resp.status}`;
log?.warn?.(`[chatSearch] upstream error provider=${provider} status=${resp.status}`);
return {
success: false,
status: resp.status,
error: typeof errMsg === "string" ? errMsg : JSON.stringify(errMsg)
};
}
const { text, citations, tokens } = cfg.extractAnswer(data);
const retrievedAt = new Date().toISOString();
const limited = (citations || []).slice(0, limit);
const results = limited.map((c, i) => toResult(c, i, provider, retrievedAt));
return {
success: true,
status: 200,
data: {
provider,
query,
results,
answer: { source: provider, text: text || "", model: useModel },
usage: { queries_used: 1, search_cost_usd: 0, llm_tokens: tokens || 0 },
metrics: {
response_time_ms: Date.now() - startTime,
upstream_latency_ms: upstreamLatency,
total_results_available: null
},
errors: []
}
};
}
export { CHAT_SEARCH_CONFIG };