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:
decolua
2026-04-28 17:28:57 +07:00
parent 1bb621317d
commit 8f81363675
45 changed files with 2924 additions and 289 deletions

View File

@@ -21,7 +21,7 @@ export async function GET() {
export async function POST(request) {
try {
const body = await request.json();
const { name, models } = body;
const { name, models, kind } = body;
if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
@@ -38,7 +38,7 @@ export async function POST(request) {
return NextResponse.json({ error: "Combo name already exists" }, { status: 400 });
}
const combo = await createCombo({ name, models: models || [] });
const combo = await createCombo({ name, models: models || [], kind: kind || null });
return NextResponse.json(combo, { status: 201 });
} catch (error) {

View File

@@ -1,17 +1,53 @@
import { NextResponse } from "next/server";
import { getProviderNodeById } from "@/models";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider, AI_PROVIDERS } from "@/shared/constants/providers";
import { getDefaultModel } from "open-sse/config/providerModels.js";
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
import { PROVIDER_ENDPOINTS } from "@/shared/constants/config";
// Probe a webSearch/webFetch provider using its searchConfig/fetchConfig.
// Returns true if API key is accepted (status !== 401 && !== 403).
async function probeWebProvider(provider, apiKey) {
const p = AI_PROVIDERS[provider];
if (!p) return null;
// Skip if provider has dual-purpose (LLM + search), let LLM validate handle it
const kinds = p.serviceKinds || ["llm"];
const isWebOnly = kinds.every((k) => k === "webSearch" || k === "webFetch");
if (!isWebOnly) return null;
const cfg = p.searchConfig || p.fetchConfig;
if (!cfg) return null;
if (cfg.authType === "none") return true; // no-auth (e.g. searxng)
let url = cfg.baseUrl;
const headers = { "Content-Type": "application/json" };
let body;
// Apply auth based on authHeader
switch (cfg.authHeader) {
case "bearer": headers["Authorization"] = `Bearer ${apiKey}`; break;
case "x-api-key": headers["x-api-key"] = apiKey; break;
case "x-subscription-token":headers["x-subscription-token"] = apiKey; break;
case "key": url += `?key=${encodeURIComponent(apiKey)}&q=ping&cx=test`; break; // google-pse
case "api_key": url += `?api_key=${encodeURIComponent(apiKey)}&q=ping&engine=google`; break; // searchapi
}
// Minimal body for POST endpoints; GET sends nothing
if (cfg.method === "POST") {
body = JSON.stringify({ query: "ping", q: "ping", url: "https://example.com" });
}
const res = await fetch(url, { method: cfg.method, headers, body, signal: AbortSignal.timeout(8000) });
return res.status !== 401 && res.status !== 403;
}
// POST /api/providers/validate - Validate API key with provider
export async function POST(request) {
try {
const body = await request.json();
const { provider, apiKey, providerSpecificData } = body;
if (!provider || (!apiKey && provider !== "ollama-local")) {
const isNoAuth = AI_PROVIDERS[provider]?.noAuth === true;
if (!provider || (!apiKey && provider !== "ollama-local" && !isNoAuth)) {
return NextResponse.json({ error: "Provider and API key required" }, { status: 400 });
}
@@ -147,6 +183,15 @@ export async function POST(request) {
});
}
// Generic probe for webSearch/webFetch providers (config-driven)
const webResult = await probeWebProvider(provider, apiKey);
if (webResult !== null) {
return NextResponse.json({
valid: webResult,
error: webResult ? null : "Invalid API key",
});
}
switch (provider) {
case "openai":
const openaiRes = await fetch("https://api.openai.com/v1/models", {
@@ -294,7 +339,12 @@ export async function POST(request) {
const headers = {};
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(endpoints[provider], { headers });
isValid = res.ok;
// xai returns 400 for bad key, 403 for valid-but-no-credit. Other providers use 401.
if (provider === "xai") {
isValid = res.status === 200 || res.status === 403;
} else {
isValid = res.ok;
}
break;
}

View File

@@ -4,6 +4,7 @@ import "open-sse/index.js";
import { getProviderConnectionById, updateProviderConnection } from "@/lib/localDb";
import { getUsageForProvider } from "open-sse/services/usage.js";
import { getExecutor } from "open-sse/executors/index.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
// Detect auth-expired messages returned by usage providers instead of throwing
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
@@ -18,7 +19,7 @@ function isAuthExpiredMessage(usage) {
* @param {boolean} force - Skip needsRefresh check and always attempt refresh
* @returns Promise<{ connection, refreshed: boolean }>
*/
async function refreshAndUpdateCredentials(connection, force = false) {
async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
const executor = getExecutor(connection.provider);
// Build credentials object from connection
@@ -39,8 +40,8 @@ async function refreshAndUpdateCredentials(connection, force = false) {
return { connection, refreshed: false };
}
// Use executor's refreshCredentials method
const refreshResult = await executor.refreshCredentials(credentials, console);
// Use executor's refreshCredentials method (with optional proxy)
const refreshResult = await executor.refreshCredentials(credentials, console, proxyOptions);
if (!refreshResult) {
// Refresh failed but we still have an accessToken — try with existing token
@@ -117,9 +118,19 @@ export async function GET(request, { params }) {
return Response.json({ message: "Usage not available for API key connections" });
}
// Resolve connection proxy config; force strictProxy=false so quota/refresh fall back to direct on failure
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
const proxyOptions = {
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
connectionNoProxy: proxyConfig.connectionNoProxy || "",
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
strictProxy: false,
};
// Refresh credentials if needed using executor
try {
const result = await refreshAndUpdateCredentials(connection);
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
connection = result.connection;
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", refreshError);
@@ -129,15 +140,15 @@ export async function GET(request, { params }) {
}
// Fetch usage from provider API
let usage = await getUsageForProvider(connection);
let usage = await getUsageForProvider(connection, proxyOptions);
// If provider returned an auth-expired message instead of throwing,
// force-refresh token and retry once
if (isAuthExpiredMessage(usage) && connection.refreshToken) {
try {
const retryResult = await refreshAndUpdateCredentials(connection, true);
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
connection = retryResult.connection;
usage = await getUsageForProvider(connection);
usage = await getUsageForProvider(connection, proxyOptions);
} catch (retryError) {
console.warn(`[Usage] ${connection.provider}: force refresh failed: ${retryError.message}`);
}

View File

@@ -0,0 +1,21 @@
import { handleSearch } from "@/sse/handlers/search.js";
/**
* Handle CORS preflight
*/
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*"
}
});
}
/**
* POST /v1/search - Web search endpoint
*/
export async function POST(request) {
return await handleSearch(request);
}

View File

@@ -0,0 +1,21 @@
import { handleFetch } from "@/sse/handlers/fetch.js";
/**
* Handle CORS preflight
*/
export async function OPTIONS() {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "*"
}
});
}
/**
* POST /v1/web/fetch - Web URL fetch/extract endpoint
*/
export async function POST(request) {
return await handleFetch(request);
}