diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js index f815735..9a4a4b8 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenCodeToolCard.js @@ -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 [{ diff --git a/src/app/api/cli-tools/opencode-settings/route.js b/src/app/api/cli-tools/opencode-settings/route.js index 4ff6be8..f4429cf 100644 --- a/src/app/api/cli-tools/opencode-settings/route.js +++ b/src/app/api/cli-tools/opencode-settings/route.js @@ -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 diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 2942352..b4c27b9 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -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) { diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index ab7abc9..4fe297c 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -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}/`)) {