Enhance model configuration by adding modalities for input and output in OpenCodeToolCard and route.js. Introduce live model resolvers for Kiro in v1/models, allowing dynamic fetching of models based on provider credentials.

This commit is contained in:
decolua 2026-05-16 11:20:20 +07:00
parent 4a575f21a2
commit 18f87f43ca
4 changed files with 169 additions and 153 deletions

View file

@ -192,7 +192,7 @@ export default function OpenCodeToolCard({ tool, isExpanded, onToggle, baseUrl,
const modelsObj = {};
modelsToShow.forEach(m => {
modelsObj[m] = { name: m };
modelsObj[m] = { name: m, modalities: { input: ["text", "image"], output: ["text"] } };
});
return [{

View file

@ -128,7 +128,7 @@ export async function POST(request) {
// Add or update entries for all requested models
for (const m of modelsArray) {
if (!m || typeof m !== "string") continue;
existingProvider.models[m] = { name: m };
existingProvider.models[m] = { name: m, modalities: { input: ["text", "image"], output: ["text"] } };
}
// Save merged provider back

View file

@ -1,9 +1,8 @@
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, refreshKiroToken } from "@/sse/services/tokenRefresh";
import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
import { resolveOllamaLocalHost } from "open-sse/config/providers.js";
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
@ -79,6 +78,45 @@ const resolveQwenModelsUrl = (connection) => {
return `https://${value.replace(/\/$/, "")}/v1/models`;
};
// 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: {
@ -200,7 +238,90 @@ const PROVIDER_MODELS_CONFIG = {
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")
assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models"),
// 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 };
}
},
"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"
})
},
"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) };
}
}
};
/**
@ -289,154 +410,6 @@ export async function GET(request, { params }) {
});
}
// Kiro: Use resolveKiroModels to fetch live catalog + expand variants
if (connection.provider === "kiro") {
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) {
const 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
}));
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models
});
}
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);
}
// Empty dynamic list → UI falls back to static provider models.
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models: [],
warning,
});
}
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,
});
}
if (connection.provider === "ollama-local") {
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 NextResponse.json(
{ error: `Failed to fetch models: ${response.status}` },
{ status: response.status }
);
}
const data = await response.json();
const models = parseOpenAIStyleModels(data);
return NextResponse.json({
provider: connection.provider,
connectionId: connection.id,
models,
});
}
const config = PROVIDER_MODELS_CONFIG[connection.provider];
if (!config) {
return NextResponse.json(
@ -445,6 +418,20 @@ export async function GET(request, { params }) {
);
}
// 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) {

View file

@ -9,6 +9,20 @@ import { getProviderConnections, getCombos, getCustomModels, getModelAliases } f
import { getDisabledModels } from "@/lib/disabledModelsDb";
import { resolveKiroModels } from "open-sse/services/kiroModels.js";
// Per-provider live model resolvers. Each receives a connection record and
// returns { models: [{ id, name? }, ...] } | null on failure.
// Adding a provider here makes /v1/models prefer the live catalog for it.
const LIVE_MODEL_RESOLVERS = {
kiro: async (conn) => {
const result = await resolveKiroModels({
accessToken: conn.accessToken,
refreshToken: conn.refreshToken,
providerSpecificData: conn.providerSpecificData || {}
}, { log: console });
return result?.models?.length ? { models: result.models } : null;
}
};
const parseOpenAIStyleModels = (data) => {
if (Array.isArray(data)) return data;
return data?.data || data?.models || data?.results || [];
@ -255,6 +269,21 @@ export async function buildModelsList(kindFilter) {
rawModelIds = await fetchCompatibleModelIds(conn);
}
// Config-driven live catalog override (e.g. Kiro returns dynamic
// -thinking/-agentic variants per account). On failure, fall back to
// whatever rawModelIds already holds.
const liveResolver = LIVE_MODEL_RESOLVERS[providerId];
if (liveResolver && !hasExplicitEnabledModels) {
try {
const live = await liveResolver(conn);
if (live?.models?.length) {
rawModelIds = live.models.map((m) => m.id);
}
} catch (err) {
console.log(`Live model fetch failed for ${providerId}: ${err?.message || err}`);
}
}
const modelIds = rawModelIds
.map((modelId) => {
if (modelId.startsWith(`${outputAlias}/`)) {