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>
377 lines
13 KiB
JavaScript
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 });
|
|
}
|
|
}
|