9router/src/app/api/providers/[id]/models/route.js
Quan 07717bad60 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>
2026-02-25 11:40:50 +07:00

377 lines
13 KiB
JavaScript

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 = {
claude: {
url: "https://api.anthropic.com/v1/models",
method: "GET",
headers: {
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json"
},
authHeader: "x-api-key",
parseResponse: (data) => data.data || []
},
gemini: {
url: "https://generativelanguage.googleapis.com/v1beta/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authQuery: "key", // Use query param for API key
parseResponse: (data) => data.models || []
},
qwen: {
url: "https://portal.qwen.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || []
},
antigravity: {
url: "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:models",
method: "POST",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
body: {},
parseResponse: (data) => data.models || []
},
github: {
url: "https://api.githubcopilot.com/models",
method: "GET",
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) => {
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",
headers: {
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json"
},
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")
};
/**
* GET /api/providers/[id]/models - Get models list from provider
*/
export async function GET(request, { params }) {
try {
const { id } = await params;
const connection = await getProviderConnectionById(id);
if (!connection) {
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
}
if (isOpenAICompatibleProvider(connection.provider)) {
const baseUrl = connection.providerSpecificData?.baseUrl;
if (!baseUrl) {
return NextResponse.json({ error: "No base URL configured for OpenAI compatible provider" }, { status: 400 });
}
const url = `${baseUrl.replace(/\/$/, "")}/models`;
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${connection.apiKey}`,
},
});
if (!response.ok) {
const errorText = await response.text();
console.log(`Error fetching models from ${connection.provider}:`, errorText);
return NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
const models = data.data || data.models || [];
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models
});
}
if (isAnthropicCompatibleProvider(connection.provider)) {
let baseUrl = connection.providerSpecificData?.baseUrl;
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",
headers: {
"Content-Type": "application/json",
"x-api-key": connection.apiKey,
"anthropic-version": "2023-06-01",
"Authorization": `Bearer ${connection.apiKey}`
},
});
if (!response.ok) {
const errorText = await response.text();
console.log(`Error fetching models from ${connection.provider}:`, errorText);
return NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
const models = data.data || data.models || [];
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models
});
}
// 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(
{ error: `Provider ${connection.provider} does not support models listing` },
{ status: 400 }
);
}
// Get auth token
const token = connection.providerSpecificData?.copilotToken || connection.accessToken || connection.apiKey;
if (!token) {
return NextResponse.json({ error: "No valid token found" }, { status: 401 });
}
// Build request URL
let url = config.url;
if (config.authQuery) {
url += `?${config.authQuery}=${token}`;
}
// Build headers
const headers = { ...config.headers };
if (config.authHeader && !config.authQuery) {
headers[config.authHeader] = (config.authPrefix || "") + token;
}
// Make request
const fetchOptions = {
method: config.method,
headers
};
if (config.body && config.method === "POST") {
fetchOptions.body = JSON.stringify(config.body);
}
const response = await fetch(url, fetchOptions);
if (!response.ok) {
const errorText = await response.text();
console.log(`Error fetching models from ${connection.provider}:`, errorText);
return NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
const models = config.parseResponse(data);
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models
});
} catch (error) {
console.log("Error fetching provider models:", error);
return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 });
}
}