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:
Quan
2026-02-25 11:40:50 +07:00
committed by decolua
parent 147fc168f9
commit 07717bad60
30 changed files with 773 additions and 159 deletions

View File

@@ -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 });
}

View File

@@ -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);

View File

@@ -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" };
}

View File

@@ -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;

View File

@@ -7,12 +7,12 @@ import { getExecutor } from "open-sse/executors/index.js";
*/
async function refreshAndUpdateCredentials(connection) {
const executor = getExecutor(connection.provider);
// Build credentials object from connection
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
expiresAt: connection.tokenExpiresAt,
expiresAt: connection.expiresAt || connection.tokenExpiresAt,
providerSpecificData: connection.providerSpecificData,
// For GitHub
copilotToken: connection.providerSpecificData?.copilotToken,
@@ -21,7 +21,7 @@ async function refreshAndUpdateCredentials(connection) {
// Check if refresh is needed
const needsRefresh = executor.needsRefresh(credentials);
if (!needsRefresh) {
return { connection, refreshed: false };
}
@@ -55,9 +55,9 @@ async function refreshAndUpdateCredentials(connection) {
// Update token expiry
if (refreshResult.expiresIn) {
updateData.tokenExpiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
updateData.expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
} else if (refreshResult.expiresAt) {
updateData.tokenExpiresAt = refreshResult.expiresAt;
updateData.expiresAt = refreshResult.expiresAt;
}
// Handle provider-specific data (copilotToken for GitHub, etc.)
@@ -77,7 +77,7 @@ async function refreshAndUpdateCredentials(connection) {
...connection,
...updateData,
};
return {
connection: updatedConnection,
refreshed: true,
@@ -90,7 +90,7 @@ async function refreshAndUpdateCredentials(connection) {
export async function GET(request, { params }) {
try {
const { connectionId } = await params;
// Get connection from database
let connection = await getProviderConnectionById(connectionId);
if (!connection) {
@@ -108,8 +108,8 @@ export async function GET(request, { params }) {
connection = result.connection;
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", refreshError);
return Response.json({
error: `Credential refresh failed: ${refreshError.message}`
return Response.json({
error: `Credential refresh failed: ${refreshError.message}`
}, { status: 401 });
}

View File

@@ -1,4 +1,5 @@
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
import { getProviderAlias } from "@/shared/constants/providers";
import { getProviderConnections, getCombos } from "@/lib/localDb";
/**
@@ -39,11 +40,12 @@ export async function GET() {
console.log("Could not fetch combos");
}
// Build set of active provider aliases
const activeAliases = new Set();
// Build first active connection per provider (connections already sorted by priority)
const activeConnectionByProvider = new Map();
for (const conn of connections) {
const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
activeAliases.add(alias);
if (!activeConnectionByProvider.has(conn.provider)) {
activeConnectionByProvider.set(conn.provider, conn);
}
}
// Collect models from active providers (or all if none active)
@@ -64,22 +66,68 @@ export async function GET() {
}
// Add provider models
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
// If we have active providers, only include those; otherwise include all
if (connections.length > 0 && !activeAliases.has(alias)) {
continue;
if (connections.length === 0) {
// DB unavailable or no active providers -> return all static models
for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
for (const model of providerModels) {
models.push({
id: `${alias}/${model.id}`,
object: "model",
created: timestamp,
owned_by: alias,
permission: [],
root: model.id,
parent: null,
});
}
}
} else {
for (const [providerId, conn] of activeConnectionByProvider.entries()) {
const staticAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
const outputAlias = getProviderAlias(providerId) || staticAlias;
const providerModels = PROVIDER_MODELS[staticAlias] || [];
const enabledModels = conn?.providerSpecificData?.enabledModels;
const hasExplicitEnabledModels =
Array.isArray(enabledModels) && enabledModels.length > 0;
for (const model of providerModels) {
models.push({
id: `${alias}/${model.id}`,
object: "model",
created: timestamp,
owned_by: alias,
permission: [],
root: model.id,
parent: null,
});
// Default: if no explicit selection, all static models are active.
// If explicit selection exists, expose exactly those model IDs (including non-static IDs).
const rawModelIds = hasExplicitEnabledModels
? Array.from(
new Set(
enabledModels.filter(
(modelId) => typeof modelId === "string" && modelId.trim() !== "",
),
),
)
: providerModels.map((model) => model.id);
const modelIds = rawModelIds
.map((modelId) => {
if (modelId.startsWith(`${outputAlias}/`)) {
return modelId.slice(outputAlias.length + 1);
}
if (modelId.startsWith(`${staticAlias}/`)) {
return modelId.slice(staticAlias.length + 1);
}
if (modelId.startsWith(`${providerId}/`)) {
return modelId.slice(providerId.length + 1);
}
return modelId;
})
.filter((modelId) => typeof modelId === "string" && modelId.trim() !== "");
for (const modelId of modelIds) {
models.push({
id: `${outputAlias}/${modelId}`,
object: "model",
created: timestamp,
owned_by: outputAlias,
permission: [],
root: modelId,
parent: null,
});
}
}
}