feat: cherry-pick PR #183 — multi-provider support, PWA, dynamic models, UI improvements
Cherry-picked from decolua/9router PR #183. Note: open-sse changes included but need further review due to extensive modifications. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,48 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
import { KiroService } from "@/lib/oauth/services/kiro";
|
||||
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
|
||||
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
|
||||
|
||||
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
|
||||
|
||||
const parseOpenAIStyleModels = (data) => {
|
||||
if (Array.isArray(data)) return data;
|
||||
return data?.data || data?.models || data?.results || [];
|
||||
};
|
||||
|
||||
const parseGeminiCliModels = (data) => {
|
||||
if (Array.isArray(data?.models)) {
|
||||
return data.models
|
||||
.map((item) => {
|
||||
const id = item?.id || item?.model || item?.name;
|
||||
if (!id) return null;
|
||||
return { id, name: item?.displayName || item?.name || id };
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (data?.models && typeof data.models === "object") {
|
||||
return Object.entries(data.models)
|
||||
.filter(([, info]) => !info?.isInternal)
|
||||
.map(([id, info]) => ({
|
||||
id,
|
||||
name: info?.displayName || info?.name || id,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const createOpenAIModelsConfig = (url) => ({
|
||||
url,
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: parseOpenAIStyleModels
|
||||
});
|
||||
|
||||
// Provider models endpoints configuration
|
||||
const PROVIDER_MODELS_CONFIG = {
|
||||
@@ -21,14 +63,6 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
authQuery: "key", // Use query param for API key
|
||||
parseResponse: (data) => data.models || []
|
||||
},
|
||||
"gemini-cli": {
|
||||
url: "https://generativelanguage.googleapis.com/v1beta/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.models || []
|
||||
},
|
||||
qwen: {
|
||||
url: "https://portal.qwen.ai/v1/models",
|
||||
method: "GET",
|
||||
@@ -46,22 +80,35 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
body: {},
|
||||
parseResponse: (data) => data.models || []
|
||||
},
|
||||
openai: {
|
||||
url: "https://api.openai.com/v1/models",
|
||||
github: {
|
||||
url: "https://api.githubcopilot.com/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Copilot-Integration-Id": "vscode-chat",
|
||||
"editor-version": "vscode/1.107.1",
|
||||
"editor-plugin-version": "copilot-chat/0.26.7",
|
||||
"user-agent": "GitHubCopilotChat/0.26.7"
|
||||
},
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || []
|
||||
},
|
||||
openrouter: {
|
||||
url: "https://openrouter.ai/api/v1/models",
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
parseResponse: (data) => data.data || []
|
||||
parseResponse: (data) => {
|
||||
if (!data?.data) return [];
|
||||
// Filter out embeddings, non-chat models, and disabled models
|
||||
return data.data
|
||||
.filter(m => m.capabilities?.type === "chat")
|
||||
.filter(m => m.policy?.state !== "disabled") // Only return explicitly enabled models
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
name: m.name || m.id,
|
||||
version: m.version,
|
||||
capabilities: m.capabilities,
|
||||
isDefault: m.model_picker_enabled === true
|
||||
}));
|
||||
}
|
||||
},
|
||||
openai: createOpenAIModelsConfig("https://api.openai.com/v1/models"),
|
||||
openrouter: createOpenAIModelsConfig("https://openrouter.ai/api/v1/models"),
|
||||
anthropic: {
|
||||
url: "https://api.anthropic.com/v1/models",
|
||||
method: "GET",
|
||||
@@ -71,7 +118,25 @@ const PROVIDER_MODELS_CONFIG = {
|
||||
},
|
||||
authHeader: "x-api-key",
|
||||
parseResponse: (data) => data.data || []
|
||||
}
|
||||
},
|
||||
|
||||
// OpenAI-compatible API key providers
|
||||
deepseek: createOpenAIModelsConfig("https://api.deepseek.com/models"),
|
||||
groq: createOpenAIModelsConfig("https://api.groq.com/openai/v1/models"),
|
||||
xai: createOpenAIModelsConfig("https://api.x.ai/v1/models"),
|
||||
mistral: createOpenAIModelsConfig("https://api.mistral.ai/v1/models"),
|
||||
perplexity: createOpenAIModelsConfig("https://api.perplexity.ai/models"),
|
||||
together: createOpenAIModelsConfig("https://api.together.xyz/v1/models"),
|
||||
fireworks: createOpenAIModelsConfig("https://api.fireworks.ai/inference/v1/models"),
|
||||
cerebras: createOpenAIModelsConfig("https://api.cerebras.ai/v1/models"),
|
||||
cohere: createOpenAIModelsConfig("https://api.cohere.ai/v1/models"),
|
||||
nebius: createOpenAIModelsConfig("https://api.studio.nebius.ai/v1/models"),
|
||||
siliconflow: createOpenAIModelsConfig("https://api.siliconflow.cn/v1/models"),
|
||||
hyperbolic: createOpenAIModelsConfig("https://api.hyperbolic.xyz/v1/models"),
|
||||
nanobanana: createOpenAIModelsConfig("https://api.nanobananaapi.ai/v1/models"),
|
||||
chutes: createOpenAIModelsConfig("https://llm.chutes.ai/v1/models"),
|
||||
nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
|
||||
assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models")
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -124,12 +189,12 @@ export async function GET(request, { params }) {
|
||||
if (!baseUrl) {
|
||||
return NextResponse.json({ error: "No base URL configured for Anthropic compatible provider" }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
baseUrl = baseUrl.replace(/\/$/, "");
|
||||
if (baseUrl.endsWith("/messages")) {
|
||||
baseUrl = baseUrl.slice(0, -9);
|
||||
}
|
||||
|
||||
|
||||
const url = `${baseUrl}/models`;
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
@@ -160,6 +225,96 @@ export async function GET(request, { params }) {
|
||||
});
|
||||
}
|
||||
|
||||
// Kiro: Try dynamic model fetching first
|
||||
if (connection.provider === "kiro") {
|
||||
try {
|
||||
const kiroService = new KiroService();
|
||||
const profileArn = connection.providerSpecificData?.profileArn;
|
||||
const accessToken = connection.accessToken;
|
||||
|
||||
if (accessToken && profileArn) {
|
||||
const models = await kiroService.listAvailableModels(accessToken, profileArn);
|
||||
return NextResponse.json({
|
||||
provider: connection.provider,
|
||||
connectionId: connection.id,
|
||||
models
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Failed to fetch Kiro models dynamically, falling back to static:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (connection.provider === "gemini-cli") {
|
||||
const { accessToken, refreshToken } = connection;
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: "No valid token found" }, { status: 401 });
|
||||
}
|
||||
|
||||
const projectId = connection.projectId || connection.providerSpecificData?.projectId;
|
||||
const body = projectId ? { project: projectId } : {};
|
||||
|
||||
const fetchModels = async (token) => {
|
||||
const response = await fetch(GEMINI_CLI_MODELS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"User-Agent": "google-api-nodejs-client/9.15.1",
|
||||
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
let warning;
|
||||
|
||||
try {
|
||||
let response = await fetchModels(accessToken);
|
||||
|
||||
// Attempt refresh on 401/403 when refresh token exists
|
||||
if (!response.ok && (response.status === 401 || response.status === 403) && refreshToken) {
|
||||
const refreshed = await refreshGoogleToken(refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret);
|
||||
if (refreshed?.accessToken) {
|
||||
await updateProviderCredentials(connection.id, {
|
||||
accessToken: refreshed.accessToken,
|
||||
refreshToken: refreshed.refreshToken,
|
||||
expiresIn: refreshed.expiresIn,
|
||||
});
|
||||
response = await fetchModels(refreshed.accessToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const models = parseGeminiCliModels(data);
|
||||
if (models.length > 0) {
|
||||
return NextResponse.json({
|
||||
provider: connection.provider,
|
||||
connectionId: connection.id,
|
||||
models
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
warning = `Failed to fetch Gemini CLI models: ${response.status} ${errorText}`;
|
||||
console.log("Failed to fetch Gemini CLI models dynamically, falling back to static:", errorText);
|
||||
}
|
||||
} catch (error) {
|
||||
warning = `Failed to fetch Gemini CLI models: ${error.message}`;
|
||||
console.log("Failed to fetch Gemini CLI models dynamically, falling back to static:", error.message);
|
||||
}
|
||||
|
||||
// Return empty dynamic list so UI falls back to static provider models.
|
||||
return NextResponse.json({
|
||||
provider: connection.provider,
|
||||
connectionId: connection.id,
|
||||
models: [],
|
||||
warning,
|
||||
});
|
||||
}
|
||||
|
||||
const config = PROVIDER_MODELS_CONFIG[connection.provider];
|
||||
if (!config) {
|
||||
return NextResponse.json(
|
||||
@@ -169,7 +324,7 @@ export async function GET(request, { params }) {
|
||||
}
|
||||
|
||||
// Get auth token
|
||||
const token = connection.accessToken || connection.apiKey;
|
||||
const token = connection.providerSpecificData?.copilotToken || connection.accessToken || connection.apiKey;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "No valid token found" }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -30,7 +30,18 @@ export async function PUT(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { name, priority, globalPriority, defaultModel, isActive, apiKey, testStatus, lastError, lastErrorAt } = body;
|
||||
const {
|
||||
name,
|
||||
priority,
|
||||
globalPriority,
|
||||
defaultModel,
|
||||
isActive,
|
||||
apiKey,
|
||||
testStatus,
|
||||
lastError,
|
||||
lastErrorAt,
|
||||
providerSpecificData
|
||||
} = body;
|
||||
|
||||
const existing = await getProviderConnectionById(id);
|
||||
if (!existing) {
|
||||
@@ -47,6 +58,12 @@ export async function PUT(request, { params }) {
|
||||
if (testStatus !== undefined) updateData.testStatus = testStatus;
|
||||
if (lastError !== undefined) updateData.lastError = lastError;
|
||||
if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt;
|
||||
if (providerSpecificData !== undefined) {
|
||||
updateData.providerSpecificData = {
|
||||
...(existing.providerSpecificData || {}),
|
||||
...providerSpecificData,
|
||||
};
|
||||
}
|
||||
|
||||
const updated = await updateProviderConnection(id, updateData);
|
||||
|
||||
|
||||
@@ -281,6 +281,58 @@ async function testApiKeyConnection(connection) {
|
||||
const res = await fetch("https://api.x.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "nvidia": {
|
||||
const res = await fetch("https://integrate.api.nvidia.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "perplexity": {
|
||||
const res = await fetch("https://api.perplexity.ai/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "together": {
|
||||
const res = await fetch("https://api.together.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "fireworks": {
|
||||
const res = await fetch("https://api.fireworks.ai/inference/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "cerebras": {
|
||||
const res = await fetch("https://api.cerebras.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "cohere": {
|
||||
const res = await fetch("https://api.cohere.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "nebius": {
|
||||
const res = await fetch("https://api.studio.nebius.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "siliconflow": {
|
||||
const res = await fetch("https://api.siliconflow.cn/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "hyperbolic": {
|
||||
const res = await fetch("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "deepgram": {
|
||||
const res = await fetch("https://api.deepgram.com/v1/projects", { headers: { Authorization: `Token ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "assemblyai": {
|
||||
const res = await fetch("https://api.assemblyai.com/v1/account", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "nanobanana": {
|
||||
const res = await fetch("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
case "chutes": {
|
||||
const res = await fetch("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
|
||||
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
|
||||
}
|
||||
default:
|
||||
return { valid: false, error: "Provider test not supported" };
|
||||
}
|
||||
|
||||
@@ -38,22 +38,22 @@ export async function POST(request) {
|
||||
if (!node) {
|
||||
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
|
||||
let normalizedBase = node.baseUrl?.trim().replace(/\/$/, "") || "";
|
||||
if (normalizedBase.endsWith("/messages")) {
|
||||
normalizedBase = normalizedBase.slice(0, -9); // remove /messages
|
||||
}
|
||||
|
||||
|
||||
const modelsUrl = `${normalizedBase}/models`;
|
||||
|
||||
|
||||
const res = await fetch(modelsUrl, {
|
||||
headers: {
|
||||
headers: {
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
"Authorization": `Bearer ${apiKey}`
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
isValid = res.ok;
|
||||
return NextResponse.json({
|
||||
valid: isValid,
|
||||
@@ -145,8 +145,57 @@ export async function POST(request) {
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
|
||||
case "deepseek":
|
||||
case "groq":
|
||||
case "xai":
|
||||
case "mistral":
|
||||
case "perplexity":
|
||||
case "together":
|
||||
case "fireworks":
|
||||
case "cerebras":
|
||||
case "cohere":
|
||||
case "nebius":
|
||||
case "siliconflow":
|
||||
case "hyperbolic":
|
||||
case "assemblyai":
|
||||
case "nanobanana":
|
||||
case "chutes":
|
||||
case "nvidia": {
|
||||
const endpoints = {
|
||||
deepseek: "https://api.deepseek.com/models",
|
||||
groq: "https://api.groq.com/openai/v1/models",
|
||||
xai: "https://api.x.ai/v1/models",
|
||||
mistral: "https://api.mistral.ai/v1/models",
|
||||
perplexity: "https://api.perplexity.ai/models",
|
||||
together: "https://api.together.xyz/v1/models",
|
||||
fireworks: "https://api.fireworks.ai/inference/v1/models",
|
||||
cerebras: "https://api.cerebras.ai/v1/models",
|
||||
cohere: "https://api.cohere.ai/v1/models",
|
||||
nebius: "https://api.studio.nebius.ai/v1/models",
|
||||
siliconflow: "https://api.siliconflow.cn/v1/models",
|
||||
hyperbolic: "https://api.hyperbolic.xyz/v1/models",
|
||||
assemblyai: "https://api.assemblyai.com/v1/account",
|
||||
nanobanana: "https://api.nanobananaapi.ai/v1/models",
|
||||
chutes: "https://llm.chutes.ai/v1/models",
|
||||
nvidia: "https://integrate.api.nvidia.com/v1/models"
|
||||
};
|
||||
const res = await fetch(endpoints[provider], {
|
||||
headers: { "Authorization": `Bearer ${apiKey}` },
|
||||
});
|
||||
isValid = res.ok;
|
||||
break;
|
||||
}
|
||||
|
||||
case "deepgram": {
|
||||
const res = await fetch("https://api.deepgram.com/v1/projects", {
|
||||
headers: { "Authorization": `Token ${apiKey}` },
|
||||
});
|
||||
isValid = res.ok;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
|
||||
}
|
||||
} catch (err) {
|
||||
error = err.message;
|
||||
|
||||
Reference in New Issue
Block a user