mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
- 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.
410 lines
12 KiB
JavaScript
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 };
|