9router/src/app/api/providers/[id]/models/route.js
long2ice 6994cd1f70 fix(cursor): HTTP/2 AgentService support + version bump to 3.12.17
Real Cursor IDE now uses AgentService at agent.api5.cursor.sh (HTTP/2-only)
while 9router still spoke the retired ChatService at api2.cursor.sh with
outdated headers, producing HTTP 429 "Update Required". Add an executeAgent
path that builds an agent.v1.RunRequest Connect RPC over a raw http2 stream
and fetches the account-specific usable model catalog via GetUsableModels.

Also implement MCP tool calling over AgentService: encode OpenAI tools as
AgentRunRequest.mcp_tools (McpToolDefinition with google.protobuf.Value
input_schema), decode McpArgs tool calls, and forward them to the client as
OpenAI tool_calls so the client runs the tool and resumes in the next turn.
Reply to request_context_args with a non-empty RequestContext, to server
heartbeats with client_heartbeat, and to KV blob get/set with empty results,
so action queries no longer stall the stream. Fold the client system prompt
into the user message (custom_system_prompt makes the server return an empty
turn). Bump clientVersion to 3.12.17 and add the x-cursor-client-commit
header so the gateway identifies as a current Cursor IDE release.
2026-07-20 15:39:55 +07:00

620 lines
22 KiB
JavaScript

import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
import { refreshGoogleToken, refreshCodexToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
import { resolveKimchiModels } from "open-sse/services/kimchiModels.js";
import { resolveQoderModels } from "open-sse/services/qoderModels.js";
import { resolveGrokCliModels } from "open-sse/services/grokCliModels.js";
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
import { resolveCursorModels } from "open-sse/services/cursorModels.js";
const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
// The /codex/models endpoint gates each entry by minimal_client_version against this
// value, and codex CLI's own manifest (openai/codex codex-rs/models-manager/models.json)
// already requires 0.144.0 for its newest models, so a stale client_version here comes
// back 200 with those entries quietly missing instead of erroring.
const CODEX_CLIENT_VERSION = "0.144.6";
const CODEX_MODELS_URL = `https://chatgpt.com/backend-api/codex/models?client_version=${CODEX_CLIENT_VERSION}`;
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 appendCodexReviewModels = (models) => models.flatMap((model) => {
const id = model?.id || model?.slug || model?.model || model?.name;
if (!id) return [];
const name = model?.display_name || model?.displayName || model?.name || id;
const normalized = { ...model, id, name };
const isChatModel = (model?.type || "llm") !== "image" && !id.toLowerCase().includes("embed");
if (!isChatModel || id.endsWith("-review")) return [normalized];
return [
normalized,
{
...normalized,
id: `${id}-review`,
name: `${name} Review`,
upstreamModelId: id,
quotaFamily: "review",
},
];
});
const parseCodexModels = (data) => appendCodexReviewModels(parseOpenAIStyleModels(data));
const createOpenAIModelsConfig = (url) => ({
url,
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: parseOpenAIStyleModels
});
const resolveQwenModelsUrl = (connection) => {
const fallback = "https://portal.qwen.ai/v1/models";
const raw = connection?.providerSpecificData?.resourceUrl;
if (!raw || typeof raw !== "string") return fallback;
const value = raw.trim();
if (!value) return fallback;
if (value.startsWith("http://") || value.startsWith("https://")) {
return `${value.replace(/\/$/, "")}/models`;
}
return `https://${value.replace(/\/$/, "")}/v1/models`;
};
const getStaticProviderModels = (providerId) =>
getModelsByProviderId(providerId).map((model) => ({
...model,
id: model.id,
name: model.name || model.id,
}));
// Generic custom resolver for OAuth providers that need refresh-on-401 + token persist.
// Receives a `fetchFn(token)` and returns parsed models or throws.
const buildOAuthResolver = ({ refreshFn, fetchFn, parseFn, errorLabel }) => async (connection) => {
const { accessToken, refreshToken } = connection;
if (!accessToken) {
return { error: "No valid token found", status: 401 };
}
let warning;
try {
let response = await fetchFn(accessToken, connection);
if (!response.ok && (response.status === 401 || response.status === 403) && refreshToken) {
const refreshed = await refreshFn(connection);
if (refreshed?.accessToken) {
await updateProviderCredentials(connection.id, {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken || refreshToken,
expiresIn: refreshed.expiresIn,
});
connection.accessToken = refreshed.accessToken;
if (refreshed.refreshToken) connection.refreshToken = refreshed.refreshToken;
response = await fetchFn(refreshed.accessToken, connection);
}
}
if (response.ok) {
const data = await response.json();
const models = parseFn(data);
if (models.length > 0) return { models };
} else {
const errorText = await response.text();
warning = `${errorLabel}: ${response.status} ${errorText}`;
console.log(`${errorLabel} (falling back to static):`, errorText);
}
} catch (error) {
warning = `${errorLabel}: ${error.message}`;
console.log(`${errorLabel} (falling back to static):`, error.message);
}
return { models: [], warning };
};
// 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 || []
},
codex: {
customResolver: buildOAuthResolver({
refreshFn: (conn) => refreshCodexToken(conn.refreshToken),
fetchFn: (token) => fetch(CODEX_MODELS_URL, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": `Bearer ${token}`,
"originator": "codex_cli_rs"
}
}),
parseFn: parseCodexModels,
errorLabel: "Failed to fetch Codex models"
})
},
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 || []
},
alicode: {
url: "https://coding.dashscope.aliyuncs.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || []
},
"alicode-intl": {
url: "https://coding-intl.dashscope.aliyuncs.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || []
},
"alims-intl": {
url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || []
},
"volcengine-ark": createOpenAIModelsConfig("https://ark.cn-beijing.volces.com/api/coding/v3/models"),
byteplus: createOpenAIModelsConfig("https://ark.ap-southeast.bytepluses.com/api/coding/v3/models"),
// 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/v1/models"),
"perplexity-agent": createOpenAIModelsConfig("https://api.perplexity.ai/v1/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.com/v1/models"),
hyperbolic: createOpenAIModelsConfig("https://api.hyperbolic.xyz/v1/models"),
ollama: createOpenAIModelsConfig("https://ollama.com/api/tags"),
// ollama-local: url resolved dynamically below via providerSpecificData.baseUrl
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"),
"vercel-ai-gateway": createOpenAIModelsConfig("https://ai-gateway.vercel.sh/v1/models"),
kimchi: {
customResolver: async (connection) => {
const result = await resolveKimchiModels({
accessToken: connection.accessToken,
apiKey: connection.apiKey,
providerSpecificData: connection.providerSpecificData || {},
}, { forceRefresh: true, log: console });
if (result?.models?.length) {
return { models: result.models };
}
return {
models: getStaticProviderModels("kimchi"),
warning: "Kimchi returned no live models; falling back to static catalog.",
};
}
},
cursor: {
customResolver: async (connection) => {
const result = await resolveCursorModels({
accessToken: connection.accessToken,
providerSpecificData: connection.providerSpecificData || {},
}, { forceRefresh: true, log: console });
if (result?.models?.length) return { models: result.models };
return {
models: getStaticProviderModels("cursor"),
warning: "Cursor returned no live models; falling back to static catalog.",
};
},
},
// Custom resolvers (non-OpenAI-shaped APIs / token-refresh flows)
kiro: {
customResolver: async (connection) => {
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
providerSpecificData: connection.providerSpecificData || {}
};
let warning;
try {
const result = await resolveKiroModels(credentials, {
log: console,
onCredentialsRefreshed: async (refreshed) => {
if (refreshed?.accessToken) {
await updateProviderCredentials(connection.id, {
accessToken: refreshed.accessToken,
refreshToken: refreshed.refreshToken || connection.refreshToken,
expiresIn: refreshed.expiresIn,
});
connection.accessToken = refreshed.accessToken;
if (refreshed.refreshToken) connection.refreshToken = refreshed.refreshToken;
}
}
});
if (result?.models?.length) {
return {
models: result.models.map((m) => ({
id: m.id,
name: m.name,
upstreamModelId: m.upstreamModelId,
contextLength: m.contextLength,
rateMultiplier: m.rateMultiplier,
capabilities: m.capabilities,
description: m.description
}))
};
}
warning = "Kiro returned no models; falling back to static catalog.";
} catch (error) {
warning = `Failed to fetch Kiro models: ${error.message}`;
console.log("Failed to fetch Kiro models dynamically, falling back to static:", error.message);
}
return { models: [], warning };
}
},
qoder: {
customResolver: async (connection) => {
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
email: connection.email,
displayName: connection.displayName,
providerSpecificData: connection.providerSpecificData || {},
};
let warning;
try {
const result = await resolveQoderModels(credentials, { forceRefresh: true });
if (result?.models?.length) {
return {
models: result.models.map((m) => ({
// Use the canonical "qoder/<key>" id so the dashboard
// surfaces the same identifier the chat router expects.
id: `qoder/${m.id}`,
name: m.name,
contextLength: m.contextLength,
isVL: m.isVL,
isReasoning: m.isReasoning,
maxOutputTokens: m.maxOutputTokens,
description: m.description,
})),
};
}
warning = "Qoder returned no models; falling back to static catalog.";
} catch (error) {
warning = `Failed to fetch Qoder models: ${error.message}`;
console.log("Failed to fetch Qoder models dynamically, falling back to static:", error.message);
}
return { models: [], warning };
},
},
"gemini-cli": {
customResolver: buildOAuthResolver({
refreshFn: (conn) => refreshGoogleToken(conn.refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret),
fetchFn: (token, conn) => {
const projectId = conn.projectId || conn.providerSpecificData?.projectId;
const body = projectId ? { project: projectId } : {};
return 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)
});
},
parseFn: parseGeminiCliModels,
errorLabel: "Failed to fetch Gemini CLI models"
})
},
"grok-cli": {
customResolver: async (connection) => {
const proxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
const result = await resolveGrokCliModels({
...connection,
connectionId: connection.id,
}, {
log: console,
proxyOptions: {
connectionProxyEnabled: proxy.connectionProxyEnabled === true,
connectionProxyUrl: proxy.connectionProxyUrl || "",
connectionNoProxy: proxy.connectionNoProxy || "",
vercelRelayUrl: proxy.vercelRelayUrl || "",
strictProxy: proxy.strictProxy === true,
},
onCredentialsRefreshed: async (refreshed) => {
await updateProviderCredentials(connection.id, {
...refreshed,
existingProviderSpecificData: connection.providerSpecificData || {},
});
},
});
if (result.models.length) return result;
return {
models: getStaticProviderModels("grok-cli"),
warning: result.warning || "Grok CLI returned no live models; using static catalog.",
};
},
},
"ollama-local": {
customResolver: async (connection) => {
const url = `${resolveOllamaLocalHost(connection)}/api/tags`;
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" }
});
if (!response.ok) {
const errorText = await response.text();
console.log("Error fetching models from ollama-local:", errorText);
return { error: `Failed to fetch models: ${response.status}`, status: response.status };
}
const data = await response.json();
return { models: parseOpenAIStyleModels(data) };
}
}
};
/**
* 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
});
}
const config = PROVIDER_MODELS_CONFIG[connection.provider];
if (!config) {
return NextResponse.json(
{ error: `Provider ${connection.provider} does not support models listing` },
{ status: 400 }
);
}
// Config-driven custom resolver path (OAuth refresh, non-OpenAI shape, etc.)
if (typeof config.customResolver === "function") {
const result = await config.customResolver(connection);
if (result.error) {
return NextResponse.json({ error: result.error }, { status: result.status || 500 });
}
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models: result.models,
...(result.warning ? { warning: result.warning } : {})
});
}
// 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 (connection.provider === "qwen") {
url = resolveQwenModelsUrl(connection);
}
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 });
}
}