mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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.
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
getProviderCredentials,
|
||||
markAccountUnavailable,
|
||||
clearAccountError,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "../services/auth.js";
|
||||
import { getSettings, getCombos } from "@/lib/localDb";
|
||||
import { AI_PROVIDERS, resolveProviderId } from "@/shared/constants/providers.js";
|
||||
import { handleFetchCore } from "open-sse/handlers/fetch/index.js";
|
||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||
import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.js";
|
||||
|
||||
/**
|
||||
* Handle web fetch (URL extraction) request for the SSE/Next.js server.
|
||||
* Provider IS the model. Mirrors handleEmbeddings auth + fallback flow.
|
||||
*
|
||||
* @param {Request} request
|
||||
*/
|
||||
export async function handleFetch(request) {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
log.warn("FETCH", "Invalid JSON body");
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
const reqUrl = new URL(request.url);
|
||||
// Accept either `provider` or `model` (UI sends `model` since provider IS the model for webFetch)
|
||||
const providerInput = body.provider || body.model;
|
||||
const targetUrl = body.url;
|
||||
const format = body.format;
|
||||
const maxCharacters = body.max_characters;
|
||||
|
||||
log.request("POST", `${reqUrl.pathname} | ${providerInput}`);
|
||||
|
||||
// Log API key (masked)
|
||||
const apiKey = extractApiKey(request);
|
||||
if (apiKey) {
|
||||
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
|
||||
} else {
|
||||
log.debug("AUTH", "No API key provided (local mode)");
|
||||
}
|
||||
|
||||
// Enforce API key if enabled in settings
|
||||
const settings = await getSettings();
|
||||
if (settings.requireApiKey) {
|
||||
if (!apiKey) {
|
||||
log.warn("AUTH", "Missing API key (requireApiKey=true)");
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||
}
|
||||
const valid = await isValidApiKey(apiKey);
|
||||
if (!valid) {
|
||||
log.warn("AUTH", "Invalid API key (requireApiKey=true)");
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerInput || typeof providerInput !== "string") {
|
||||
log.warn("FETCH", "Missing provider/model");
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: provider (or model)");
|
||||
}
|
||||
|
||||
if (!targetUrl || typeof targetUrl !== "string") {
|
||||
log.warn("FETCH", "Missing url");
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: url");
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(targetUrl);
|
||||
} catch {
|
||||
log.warn("FETCH", "Invalid URL", { url: targetUrl });
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid URL format");
|
||||
}
|
||||
|
||||
// Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
|
||||
const combos = await getCombos();
|
||||
const comboModels = getComboModelsFromData(providerInput, combos);
|
||||
if (comboModels) {
|
||||
const comboStrategies = settings.comboStrategies || {};
|
||||
const comboStrategy = comboStrategies[providerInput]?.fallbackStrategy || settings.comboStrategy || "fallback";
|
||||
log.info("FETCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy})`);
|
||||
return handleComboChat({
|
||||
body,
|
||||
models: comboModels,
|
||||
handleSingleModel: (b, m) => handleSingleProviderFetch(b, m, request, apiKey, settings),
|
||||
log,
|
||||
comboName: providerInput,
|
||||
comboStrategy
|
||||
});
|
||||
}
|
||||
|
||||
return handleSingleProviderFetch(body, providerInput, request, apiKey, settings);
|
||||
}
|
||||
|
||||
async function handleSingleProviderFetch(body, providerInput, request, apiKey, settings) {
|
||||
const targetUrl = body.url;
|
||||
const format = body.format;
|
||||
const maxCharacters = body.max_characters;
|
||||
const providerId = resolveProviderId(providerInput);
|
||||
const resolvedProvider = AI_PROVIDERS[providerId];
|
||||
|
||||
if (!resolvedProvider) {
|
||||
log.warn("FETCH", "Unknown provider", { provider: providerInput });
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${providerInput}`);
|
||||
}
|
||||
|
||||
const providerConfig = resolvedProvider.fetchConfig;
|
||||
if (!providerConfig) {
|
||||
log.warn("FETCH", "Provider does not support web fetch", { provider: providerId });
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `Provider ${providerId} does not support web fetch`);
|
||||
}
|
||||
|
||||
if (providerInput !== providerId) {
|
||||
log.info("ROUTING", `${providerInput} → ${providerId}`);
|
||||
} else {
|
||||
log.info("ROUTING", `Provider: ${providerId}`);
|
||||
}
|
||||
|
||||
// No-auth fetch path (kept for parity though no current fetch provider sets noAuth)
|
||||
if (resolvedProvider.noAuth) {
|
||||
log.info("AUTH", `\x1b[32m${providerId} no-auth mode\x1b[0m`);
|
||||
const result = await handleFetchCore({
|
||||
url: targetUrl,
|
||||
format,
|
||||
maxCharacters,
|
||||
provider: resolvedProvider.id,
|
||||
providerConfig,
|
||||
credentials: null,
|
||||
log
|
||||
});
|
||||
if (result.success) {
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
||||
});
|
||||
}
|
||||
return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "Fetch failed");
|
||||
}
|
||||
|
||||
// Credential + fallback loop
|
||||
const excludeConnectionIds = new Set();
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(providerId, excludeConnectionIds);
|
||||
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (credentials?.allRateLimited) {
|
||||
const errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
log.warn("FETCH", `[${providerId}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||
return unavailableResponse(status, `[${providerId}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
||||
}
|
||||
if (excludeConnectionIds.size === 0) {
|
||||
log.error("AUTH", `No credentials for provider: ${providerId}`);
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${providerId}`);
|
||||
}
|
||||
log.warn("FETCH", "No more accounts available", { provider: providerId });
|
||||
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
|
||||
}
|
||||
|
||||
log.info("AUTH", `\x1b[32mUsing ${providerId} account: ${credentials.connectionName}\x1b[0m`);
|
||||
|
||||
const refreshedCredentials = await checkAndRefreshToken(providerId, credentials);
|
||||
|
||||
const result = await handleFetchCore({
|
||||
url: targetUrl,
|
||||
format,
|
||||
maxCharacters,
|
||||
provider: resolvedProvider.id,
|
||||
providerConfig,
|
||||
credentials: refreshedCredentials,
|
||||
log,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
await updateProviderCredentials(credentials.connectionId, {
|
||||
accessToken: newCreds.accessToken,
|
||||
refreshToken: newCreds.refreshToken,
|
||||
providerSpecificData: newCreds.providerSpecificData,
|
||||
testStatus: "active"
|
||||
});
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
await clearAccountError(credentials.connectionId, credentials);
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" }
|
||||
});
|
||||
}
|
||||
|
||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId);
|
||||
|
||||
if (shouldFallback) {
|
||||
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||
excludeConnectionIds.add(credentials.connectionId);
|
||||
lastError = result.error;
|
||||
lastStatus = result.status;
|
||||
continue;
|
||||
}
|
||||
|
||||
return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "Fetch failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
getProviderCredentials,
|
||||
markAccountUnavailable,
|
||||
clearAccountError,
|
||||
extractApiKey,
|
||||
isValidApiKey,
|
||||
} from "../services/auth.js";
|
||||
import { getSettings, getCombos } from "@/lib/localDb";
|
||||
import { AI_PROVIDERS, resolveProviderId } from "@/shared/constants/providers.js";
|
||||
import { handleSearchCore } from "open-sse/handlers/search/index.js";
|
||||
import { errorResponse, unavailableResponse } from "open-sse/utils/error.js";
|
||||
import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js";
|
||||
import * as log from "../utils/logger.js";
|
||||
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
|
||||
import { handleComboChat, getComboModelsFromData } from "open-sse/services/combo.js";
|
||||
|
||||
/**
|
||||
* Handle web search request for the SSE/Next.js server.
|
||||
* Provider IS the model (no model field). Mirrors handleEmbeddings auth + fallback flow.
|
||||
*
|
||||
* @param {Request} request
|
||||
*/
|
||||
export async function handleSearch(request) {
|
||||
let body;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
log.warn("SEARCH", "Invalid JSON body");
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
// Accept either `provider` or `model` (UI sends `model` since provider IS the model for webSearch)
|
||||
const providerInput = body.provider || body.model;
|
||||
const query = body.query;
|
||||
|
||||
log.request("POST", `${url.pathname} | ${providerInput}`);
|
||||
|
||||
// Log API key (masked)
|
||||
const apiKey = extractApiKey(request);
|
||||
if (apiKey) {
|
||||
log.debug("AUTH", `API Key: ${log.maskKey(apiKey)}`);
|
||||
} else {
|
||||
log.debug("AUTH", "No API key provided (local mode)");
|
||||
}
|
||||
|
||||
// Enforce API key if enabled in settings
|
||||
const settings = await getSettings();
|
||||
if (settings.requireApiKey) {
|
||||
if (!apiKey) {
|
||||
log.warn("AUTH", "Missing API key (requireApiKey=true)");
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Missing API key");
|
||||
}
|
||||
const valid = await isValidApiKey(apiKey);
|
||||
if (!valid) {
|
||||
log.warn("AUTH", "Invalid API key (requireApiKey=true)");
|
||||
return errorResponse(HTTP_STATUS.UNAUTHORIZED, "Invalid API key");
|
||||
}
|
||||
}
|
||||
|
||||
if (!providerInput || typeof providerInput !== "string") {
|
||||
log.warn("SEARCH", "Missing provider/model");
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: provider (or model)");
|
||||
}
|
||||
|
||||
if (!query || typeof query !== "string" || !query.trim()) {
|
||||
log.warn("SEARCH", "Missing query");
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing required field: query");
|
||||
}
|
||||
|
||||
// Combo expansion: providerInput may be a combo name → run fallback/round-robin across providers
|
||||
const combos = await getCombos();
|
||||
const comboModels = getComboModelsFromData(providerInput, combos);
|
||||
if (comboModels) {
|
||||
const comboStrategies = settings.comboStrategies || {};
|
||||
const comboStrategy = comboStrategies[providerInput]?.fallbackStrategy || settings.comboStrategy || "fallback";
|
||||
log.info("SEARCH", `Combo "${providerInput}" with ${comboModels.length} providers (strategy: ${comboStrategy})`);
|
||||
return handleComboChat({
|
||||
body,
|
||||
models: comboModels,
|
||||
handleSingleModel: (b, m) => handleSingleProviderSearch(b, m, request, apiKey, settings),
|
||||
log,
|
||||
comboName: providerInput,
|
||||
comboStrategy
|
||||
});
|
||||
}
|
||||
|
||||
return handleSingleProviderSearch(body, providerInput, request, apiKey, settings);
|
||||
}
|
||||
|
||||
async function handleSingleProviderSearch(body, providerInput, request, apiKey, settings) {
|
||||
const query = body.query;
|
||||
const providerId = resolveProviderId(providerInput);
|
||||
const resolvedProvider = AI_PROVIDERS[providerId];
|
||||
|
||||
if (!resolvedProvider) {
|
||||
log.warn("SEARCH", "Unknown provider", { provider: providerInput });
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `Unknown provider: ${providerInput}`);
|
||||
}
|
||||
|
||||
const providerConfig = resolvedProvider.searchConfig;
|
||||
const supportsSearch = !!providerConfig || !!resolvedProvider.searchViaChat;
|
||||
|
||||
if (!supportsSearch) {
|
||||
log.warn("SEARCH", "Provider does not support web search", { provider: providerId });
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `Provider ${providerId} does not support web search`);
|
||||
}
|
||||
|
||||
if (providerInput !== providerId) {
|
||||
log.info("ROUTING", `${providerInput} → ${providerId}`);
|
||||
} else {
|
||||
log.info("ROUTING", `Provider: ${providerId}`);
|
||||
}
|
||||
|
||||
// Sanitized body forwarded to core
|
||||
const coreBody = {
|
||||
query: query.trim(),
|
||||
provider: providerId,
|
||||
max_results: body.max_results,
|
||||
search_type: body.search_type,
|
||||
country: body.country,
|
||||
language: body.language,
|
||||
time_range: body.time_range,
|
||||
offset: body.offset,
|
||||
domain_filter: body.domain_filter,
|
||||
content_options: body.content_options,
|
||||
provider_options: body.provider_options
|
||||
};
|
||||
|
||||
// No-auth providers (e.g. searxng) bypass credential lookup
|
||||
if (resolvedProvider.noAuth) {
|
||||
log.info("AUTH", `\x1b[32m${providerId} no-auth mode\x1b[0m`);
|
||||
const result = await handleSearchCore({
|
||||
body: coreBody,
|
||||
provider: resolvedProvider,
|
||||
providerConfig,
|
||||
credentials: null,
|
||||
log
|
||||
});
|
||||
if (result.success) return result.response;
|
||||
return result.response;
|
||||
}
|
||||
|
||||
// Credential + fallback loop
|
||||
const excludeConnectionIds = new Set();
|
||||
let lastError = null;
|
||||
let lastStatus = null;
|
||||
|
||||
while (true) {
|
||||
const credentials = await getProviderCredentials(providerId, excludeConnectionIds);
|
||||
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
if (credentials?.allRateLimited) {
|
||||
const errorMsg = lastError || credentials.lastError || "Unavailable";
|
||||
const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE;
|
||||
log.warn("SEARCH", `[${providerId}] ${errorMsg} (${credentials.retryAfterHuman})`);
|
||||
return unavailableResponse(status, `[${providerId}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman);
|
||||
}
|
||||
if (excludeConnectionIds.size === 0) {
|
||||
log.error("AUTH", `No credentials for provider: ${providerId}`);
|
||||
return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${providerId}`);
|
||||
}
|
||||
log.warn("SEARCH", "No more accounts available", { provider: providerId });
|
||||
return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable");
|
||||
}
|
||||
|
||||
log.info("AUTH", `\x1b[32mUsing ${providerId} account: ${credentials.connectionName}\x1b[0m`);
|
||||
|
||||
const refreshedCredentials = await checkAndRefreshToken(providerId, credentials);
|
||||
|
||||
const result = await handleSearchCore({
|
||||
body: coreBody,
|
||||
provider: resolvedProvider,
|
||||
providerConfig,
|
||||
credentials: refreshedCredentials,
|
||||
log,
|
||||
onCredentialsRefreshed: async (newCreds) => {
|
||||
await updateProviderCredentials(credentials.connectionId, {
|
||||
accessToken: newCreds.accessToken,
|
||||
refreshToken: newCreds.refreshToken,
|
||||
providerSpecificData: newCreds.providerSpecificData,
|
||||
testStatus: "active"
|
||||
});
|
||||
},
|
||||
onRequestSuccess: async () => {
|
||||
await clearAccountError(credentials.connectionId, credentials);
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) return result.response;
|
||||
|
||||
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, providerId);
|
||||
|
||||
if (shouldFallback) {
|
||||
log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`);
|
||||
excludeConnectionIds.add(credentials.connectionId);
|
||||
lastError = result.error;
|
||||
lastStatus = result.status;
|
||||
continue;
|
||||
}
|
||||
|
||||
return result.response;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user