From b6a2d5827e30de1745eda084bda0d1a88778e2e0 Mon Sep 17 00:00:00 2001 From: slytechnical Date: Thu, 29 May 2025 22:20:30 -0500 Subject: [PATCH] litellm working with single provider models query --- .../__tests__/openai-usage-tracking.spec.ts | 2 +- src/api/providers/__tests__/openai.spec.ts | 2 +- src/api/providers/deepseek.ts | 2 +- src/api/providers/fetchers/index.ts | 100 ++++++++++ src/api/providers/fetchers/litellm.ts | 3 + src/api/providers/fetchers/modelCache.ts | 116 +++++++---- src/api/providers/index.ts | 2 +- src/api/providers/lmstudio.ts | 31 ++- src/api/providers/ollama.ts | 29 ++- .../{openai.ts => openai-compatible.ts} | 37 +++- src/api/providers/vscode-lm.ts | 30 ++- src/core/webview/webviewMessageHandler.ts | 154 +++++++-------- src/shared/ExtensionMessage.ts | 3 - src/shared/api.ts | 24 ++- .../src/components/settings/ApiOptions.tsx | 94 ++------- .../src/components/settings/ModelPicker.tsx | 71 ++++--- .../components/settings/providers/Glama.tsx | 25 +-- .../components/settings/providers/LiteLLM.tsx | 104 +++------- .../components/settings/providers/Ollama.tsx | 52 ++--- .../settings/providers/OpenAICompatible.tsx | 31 ++- .../settings/providers/OpenRouter.tsx | 54 ++++-- .../settings/providers/Requesty.tsx | 49 ++--- .../components/settings/providers/Unbound.tsx | 153 ++++----------- .../settings/providers/VSCodeLM.tsx | 109 ++++++----- .../components/ui/hooks/useProviderModels.ts | 180 ++++++++++++++++++ .../components/ui/hooks/useRouterModels.ts | 38 ---- .../components/ui/hooks/useSelectedModel.ts | 180 ++++++++++-------- .../src/context/ExtensionStateContext.tsx | 8 - webview-ui/src/i18n/locales/ca/settings.json | 1 + webview-ui/src/i18n/locales/de/settings.json | 1 + webview-ui/src/i18n/locales/en/settings.json | 1 + webview-ui/src/i18n/locales/es/settings.json | 1 + webview-ui/src/i18n/locales/fr/settings.json | 1 + webview-ui/src/i18n/locales/hi/settings.json | 1 + webview-ui/src/i18n/locales/it/settings.json | 1 + webview-ui/src/i18n/locales/ja/settings.json | 1 + webview-ui/src/i18n/locales/ko/settings.json | 1 + webview-ui/src/i18n/locales/nl/settings.json | 1 + webview-ui/src/i18n/locales/pl/settings.json | 1 + .../src/i18n/locales/pt-BR/settings.json | 1 + webview-ui/src/i18n/locales/ru/settings.json | 1 + webview-ui/src/i18n/locales/tr/settings.json | 1 + webview-ui/src/i18n/locales/vi/settings.json | 1 + .../src/i18n/locales/zh-CN/settings.json | 1 + .../src/i18n/locales/zh-TW/settings.json | 1 + 45 files changed, 964 insertions(+), 736 deletions(-) create mode 100644 src/api/providers/fetchers/index.ts rename src/api/providers/{openai.ts => openai-compatible.ts} (91%) create mode 100644 webview-ui/src/components/ui/hooks/useProviderModels.ts delete mode 100644 webview-ui/src/components/ui/hooks/useRouterModels.ts diff --git a/src/api/providers/__tests__/openai-usage-tracking.spec.ts b/src/api/providers/__tests__/openai-usage-tracking.spec.ts index 9888475f31..403e262b26 100644 --- a/src/api/providers/__tests__/openai-usage-tracking.spec.ts +++ b/src/api/providers/__tests__/openai-usage-tracking.spec.ts @@ -4,7 +4,7 @@ import { vitest } from "vitest" import { Anthropic } from "@anthropic-ai/sdk" import { ApiHandlerOptions } from "../../../shared/api" -import { OpenAiHandler } from "../openai" +import { OpenAiHandler } from "../openai-compatible" const mockCreate = vitest.fn() diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 81c0b45e41..4b2eee38a2 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -1,7 +1,7 @@ // npx vitest run api/providers/__tests__/openai.spec.ts import { vitest, vi } from "vitest" -import { OpenAiHandler } from "../openai" +import { OpenAiHandler } from "../openai-compatible" import { ApiHandlerOptions } from "../../../shared/api" import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 47b780d262..8fc70ff044 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -4,7 +4,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import type { ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" -import { OpenAiHandler } from "./openai" +import { OpenAiHandler } from "./openai-compatible" export class DeepSeekHandler extends OpenAiHandler { constructor(options: ApiHandlerOptions) { diff --git a/src/api/providers/fetchers/index.ts b/src/api/providers/fetchers/index.ts new file mode 100644 index 0000000000..3d5039bb7a --- /dev/null +++ b/src/api/providers/fetchers/index.ts @@ -0,0 +1,100 @@ +import { ModelRecord, GetModelsOptions, RouterName } from "../../../shared/api" +import { ProviderSettings } from "@roo-code/types" +import { WebviewMessage } from "../../../shared/WebviewMessage" + +// Actual model fetching functions from individual provider files +// These will be called by the modelCache.ts:getModels function, +// so the strategies just need to return the correct GetModelsOptions object. +// The API keys are typically handled within the getModels call or the specific fetchers if needed directly. + +export interface IModelProviderStrategy { + getOptions: ( + apiConfiguration: ProviderSettings, + message?: WebviewMessage, // For providers like LiteLLM that might take credentials from message + ) => GetModelsOptions | null + // fetchModels is not strictly needed here anymore if getModels from modelCache.ts is the single entry point + // However, if we want to keep the pattern of strategies being fully responsible for fetching, + // they would call the actual fetch functions (e.g., getOpenRouterModels, getLiteLLMModels) + // For now, let's assume the strategy's main job is to produce the correct GetModelsOptions + // and the actual fetching is centralized via modelCache.getModels(options). +} + +const openRouterStrategy: IModelProviderStrategy = { + getOptions: () => ({ provider: "openrouter" }), +} + +const requestyStrategy: IModelProviderStrategy = { + getOptions: (apiConfig) => ({ provider: "requesty", apiKey: apiConfig.requestyApiKey }), +} + +const glamaStrategy: IModelProviderStrategy = { + getOptions: () => ({ provider: "glama" }), +} + +const unboundStrategy: IModelProviderStrategy = { + getOptions: (apiConfig) => ({ provider: "unbound", apiKey: apiConfig.unboundApiKey }), +} + +const litellmStrategy: IModelProviderStrategy = { + getOptions: (apiConfig, message) => { + const apiKey = message?.values?.litellmApiKey || apiConfig.litellmApiKey + const baseUrl = message?.values?.litellmBaseUrl || apiConfig.litellmBaseUrl + if (!apiKey || !baseUrl) { + // Error will be handled by the caller in webviewMessageHandler + return null + } + return { provider: "litellm", apiKey, baseUrl } + }, +} + +const ollamaStrategy: IModelProviderStrategy = { + getOptions: (apiConfig, message) => { + const baseUrl = message?.values?.baseUrl || apiConfig.ollamaBaseUrl + return { provider: "ollama", baseUrl: baseUrl || undefined } + }, +} + +const lmStudioStrategy: IModelProviderStrategy = { + getOptions: (apiConfig, message) => { + const baseUrl = message?.values?.baseUrl || apiConfig.lmStudioBaseUrl + return { provider: "lmstudio", baseUrl: baseUrl || undefined } + }, +} + +const vsCodeLmStrategy: IModelProviderStrategy = { + getOptions: () => { + return { provider: "vscodelm" } + }, +} + +const openAICompatibleStrategy: IModelProviderStrategy = { + getOptions: (apiConfig, message) => { + const baseUrl = message?.values?.baseUrl || apiConfig.openAiBaseUrl + if (!baseUrl) { + // webviewMessageHandler will catch this null and send an error if baseUrl is essential + // For this strategy, we consider baseUrl essential for forming the options. + console.warn("[OpenAICompatibleStrategy] Base URL is missing.") + return null + } + return { + provider: "openai-compatible", + baseUrl, + apiKey: message?.values?.apiKey || apiConfig.openAiApiKey, + headers: message?.values?.openAiHeaders || apiConfig.openAiHeaders, + // Azure-specific flags can be part of apiConfig and implicitly used by OpenAiHandler if needed, + // or explicitly passed if the GetModelsOptions for openai-compatible is extended. + } + }, +} + +export const modelProviderStrategies: Record = { + openrouter: openRouterStrategy, + requesty: requestyStrategy, + glama: glamaStrategy, + unbound: unboundStrategy, + litellm: litellmStrategy, + ollama: ollamaStrategy, + lmstudio: lmStudioStrategy, + vscodelm: vsCodeLmStrategy, + "openai-compatible": openAICompatibleStrategy, +} diff --git a/src/api/providers/fetchers/litellm.ts b/src/api/providers/fetchers/litellm.ts index 093fd85888..ca4420ee58 100644 --- a/src/api/providers/fetchers/litellm.ts +++ b/src/api/providers/fetchers/litellm.ts @@ -68,6 +68,9 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise return models } catch (error: any) { console.error("Error fetching LiteLLM models:", error.message ? error.message : error) + console.log( + `[DEBUG] LiteLLM error details - isAxiosError: ${axios.isAxiosError(error)}, has response: ${!!(error as any)?.response}, has request: ${!!(error as any)?.request}`, + ) if (axios.isAxiosError(error) && error.response) { throw new Error( `Failed to fetch LiteLLM models: ${error.response.status} ${error.response.statusText}. Check base URL and API key.`, diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 12d636bc46..5270876195 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -5,7 +5,7 @@ import NodeCache from "node-cache" import { ContextProxy } from "../../../core/config/ContextProxy" import { getCacheDirectoryPath } from "../../../utils/storage" -import { RouterName, ModelRecord } from "../../../shared/api" +import { RouterName, ModelRecord, GetModelsOptions } from "../../../shared/api" import { fileExistsAtPath } from "../../../utils/fs" import { getOpenRouterModels } from "./openrouter" @@ -13,21 +13,42 @@ import { getRequestyModels } from "./requesty" import { getGlamaModels } from "./glama" import { getUnboundModels } from "./unbound" import { getLiteLLMModels } from "./litellm" -import { GetModelsOptions } from "../../../shared/api" +import { getOllamaModels } from "../ollama" +import { getLmStudioModels } from "../lmstudio" +import { getVsCodeLmModels } from "../vscode-lm" +import { getOpenAiCompatibleModels } from "../openai-compatible" + const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 }) async function writeModels(router: RouterName, data: ModelRecord) { const filename = `${router}_models.json` const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath) - await fs.writeFile(path.join(cacheDir, filename), JSON.stringify(data)) + try { + await fs.writeFile(path.join(cacheDir, filename), JSON.stringify(data)) + } catch (writeError) { + console.error(`[writeModels] Error writing ${router} models to file cache:`, writeError) + // Optionally, re-throw or handle as per application's error strategy + } } async function readModels(router: RouterName): Promise { const filename = `${router}_models.json` const cacheDir = await getCacheDirectoryPath(ContextProxy.instance.globalStorageUri.fsPath) const filePath = path.join(cacheDir, filename) - const exists = await fileExistsAtPath(filePath) - return exists ? JSON.parse(await fs.readFile(filePath, "utf8")) : undefined + try { + const exists = await fileExistsAtPath(filePath) + if (exists) { + const fileContent = await fs.readFile(filePath, "utf8") + const data = JSON.parse(fileContent) as ModelRecord + console.log(`[readModels] Successfully read and parsed ${filePath}. Data: ${JSON.stringify(data)}`) + return data + } + console.log(`[readModels] File ${filePath} does not exist.`) + return undefined + } catch (readError) { + console.error(`[readModels] Error reading ${router} models from file cache at ${filePath}:`, readError) + return undefined + } } /** @@ -44,55 +65,82 @@ async function readModels(router: RouterName): Promise export const getModels = async (options: GetModelsOptions): Promise => { const { provider } = options let models = memoryCache.get(provider) - if (models) { + + if (models && Object.keys(models).length > 0) { + console.log(`[getModels] Returning non-empty models from memory cache for ${provider}`) return models + } else if (models) { + console.log(`[getModels] Memory cache for ${provider} is empty object, treating as miss.`) } + models = await readModels(provider) + if (models && Object.keys(models).length > 0) { + console.log( + `[getModels] Returning non-empty models from file cache for ${provider} and populating memory cache.`, + ) + memoryCache.set(provider, models) // Populate memory cache with non-empty file cache data + return models + } else if (models) { + console.log(`[getModels] File cache for ${provider} is empty object, treating as miss.`) + } + + console.log(`[getModels] No valid cache hit for ${provider}, attempting to fetch from provider.`) try { + let fetchedModels: ModelRecord | undefined switch (provider) { case "openrouter": - models = await getOpenRouterModels() + fetchedModels = await getOpenRouterModels() break case "requesty": - // Requesty models endpoint requires an API key for per-user custom policies - models = await getRequestyModels(options.apiKey) + fetchedModels = await getRequestyModels(options.apiKey) break case "glama": - models = await getGlamaModels() + fetchedModels = await getGlamaModels() break case "unbound": - // Unbound models endpoint requires an API key to fetch application specific models - models = await getUnboundModels(options.apiKey) + fetchedModels = await getUnboundModels(options.apiKey) break case "litellm": - // Type safety ensures apiKey and baseUrl are always provided for litellm - models = await getLiteLLMModels(options.apiKey, options.baseUrl) + if (!options.apiKey || !options.baseUrl) { + throw new Error("LiteLLM provider requires apiKey and baseUrl.") + } + fetchedModels = await getLiteLLMModels(options.apiKey, options.baseUrl) break + case "ollama": + fetchedModels = await getOllamaModels(options.baseUrl) + break + case "lmstudio": + fetchedModels = await getLmStudioModels(options.baseUrl) + break + case "vscodelm": + fetchedModels = await getVsCodeLmModels() + break + case "openai-compatible": { + const opts = options as Extract + if (!opts.baseUrl) { + throw new Error("OpenAI-Compatible provider requires baseUrl.") + } + fetchedModels = await getOpenAiCompatibleModels(opts.baseUrl, opts.apiKey, opts.headers) + break + } default: { - // Ensures router is exhaustively checked if RouterName is a strict union const exhaustiveCheck: never = provider throw new Error(`Unknown provider: ${exhaustiveCheck}`) } } - // Cache the fetched models (even if empty, to signify a successful fetch with no models) - memoryCache.set(provider, models) - await writeModels(provider, models).catch((err) => - console.error(`[getModels] Error writing ${provider} models to file cache:`, err), - ) - - try { - models = await readModels(provider) - // console.log(`[getModels] read ${router} models from file cache`) - } catch (error) { - console.error(`[getModels] error reading ${provider} models from file cache`, error) - } - return models || {} + // Ensure fetchedModels is not undefined before caching. If a fetch truly returns no models, it should be an empty object. + const modelsToCache = fetchedModels || {} + console.log(`[getModels] Successfully fetched models for ${provider}. Caching now.`) + memoryCache.set(provider, modelsToCache) + await writeModels(provider, modelsToCache) + return modelsToCache } catch (error) { - // Log the error and re-throw it so the caller can handle it (e.g., show a UI message). - console.error(`[getModels] Failed to fetch models in modelCache for ${provider}:`, error) - - throw error // Re-throw the original error to be handled by the caller. + console.error(`[getModels] Failed to fetch models for ${provider}:`, error) + console.log(`[getModels] Clearing cache for ${provider} due to fetch error.`) + memoryCache.set(provider, {}) // Clear memory cache by setting to empty object + await writeModels(provider, {}) // Clear persisted file cache by writing empty object + throw error // Re-throw the original error } } @@ -101,5 +149,7 @@ export const getModels = async (options: GetModelsOptions): Promise * @param router - The router to flush models for. */ export const flushModels = async (router: RouterName) => { - memoryCache.del(router) + console.log(`[flushModels] Flushing both memory and file cache for ${router}`) + memoryCache.del(router) // Deleting from memory cache is fine, will be treated as miss + await writeModels(router, {}) // Write an empty object to clear the file cache } diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index dd2a65dd75..d01b8d3e61 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -4,7 +4,7 @@ export { AwsBedrockHandler } from "./bedrock" export { OpenRouterHandler } from "./openrouter" export { VertexHandler } from "./vertex" export { AnthropicVertexHandler } from "./anthropic-vertex" -export { OpenAiHandler } from "./openai" +export { OpenAiHandler } from "./openai-compatible" export { OllamaHandler } from "./ollama" export { LmStudioHandler } from "./lmstudio" export { GeminiHandler } from "./gemini" diff --git a/src/api/providers/lmstudio.ts b/src/api/providers/lmstudio.ts index bac6b05551..da8d6c6bdd 100644 --- a/src/api/providers/lmstudio.ts +++ b/src/api/providers/lmstudio.ts @@ -4,7 +4,7 @@ import axios from "axios" import type { ModelInfo } from "@roo-code/types" -import { ApiHandlerOptions, openAiModelInfoSaneDefaults } from "../../shared/api" +import { ApiHandlerOptions, openAiModelInfoSaneDefaults, ModelRecord } from "../../shared/api" import { XmlMatcher } from "../../utils/xml-matcher" import { convertToOpenAiMessages } from "../transform/openai-format" @@ -163,16 +163,31 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan } } -export async function getLmStudioModels(baseUrl = "http://localhost:1234") { +export async function getLmStudioModels(baseUrl: string = "http://localhost:1234"): Promise { try { - if (!URL.canParse(baseUrl)) { - return [] + if (baseUrl && !URL.canParse(baseUrl)) { + console.warn( + `Invalid LMStudio baseUrl provided: ${baseUrl}. Using default or expecting empty if intentionally omitted.`, + ) + if (baseUrl !== "http://localhost:1234") return {} } + const targetUrl = baseUrl || "http://localhost:1234" - const response = await axios.get(`${baseUrl}/v1/models`) - const modelsArray = response.data?.data?.map((model: any) => model.id) || [] - return [...new Set(modelsArray)] + const response = await axios.get(`${targetUrl}/v1/models`) + const modelsArray = response.data?.data?.map((model: any) => model.id) || [] // LM Studio API returns models in data.data array with id property + + const modelRecord: ModelRecord = {} + for (const modelId of new Set(modelsArray)) { + modelRecord[modelId] = { + ...openAiModelInfoSaneDefaults, + description: `LM Studio model: ${modelId}`, + inputPrice: undefined, + outputPrice: undefined, + } + } + return modelRecord } catch (error) { - return [] + console.error("Error fetching LM Studio models:", error) + return {} } } diff --git a/src/api/providers/ollama.ts b/src/api/providers/ollama.ts index 4a321895d0..101a66d972 100644 --- a/src/api/providers/ollama.ts +++ b/src/api/providers/ollama.ts @@ -4,7 +4,7 @@ import axios from "axios" import type { ModelInfo } from "@roo-code/types" -import { ApiHandlerOptions, openAiModelInfoSaneDefaults } from "../../shared/api" +import { ApiHandlerOptions, openAiModelInfoSaneDefaults, ModelRecord } from "../../shared/api" import { XmlMatcher } from "../../utils/xml-matcher" import { convertToOpenAiMessages } from "../transform/openai-format" @@ -113,16 +113,31 @@ export class OllamaHandler extends BaseProvider implements SingleCompletionHandl } } -export async function getOllamaModels(baseUrl = "http://localhost:11434") { +export async function getOllamaModels(baseUrl: string = "http://localhost:11434"): Promise { try { - if (!URL.canParse(baseUrl)) { - return [] + if (baseUrl && !URL.canParse(baseUrl)) { + console.warn( + `Invalid Ollama baseUrl provided: ${baseUrl}. Using default or expecting empty if intentionally omitted.`, + ) + if (baseUrl !== "http://localhost:11434") return {} } + const targetUrl = baseUrl || "http://localhost:11434" - const response = await axios.get(`${baseUrl}/api/tags`) + const response = await axios.get(`${targetUrl}/api/tags`) const modelsArray = response.data?.models?.map((model: any) => model.name) || [] - return [...new Set(modelsArray)] + + const modelRecord: ModelRecord = {} + for (const modelId of new Set(modelsArray)) { + modelRecord[modelId] = { + ...openAiModelInfoSaneDefaults, + description: `Ollama model: ${modelId}`, + inputPrice: undefined, + outputPrice: undefined, + } + } + return modelRecord } catch (error) { - return [] + console.error("Error fetching Ollama models:", error) + return {} } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai-compatible.ts similarity index 91% rename from src/api/providers/openai.ts rename to src/api/providers/openai-compatible.ts index 3e7324f5d9..dbdf1c6254 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai-compatible.ts @@ -4,7 +4,12 @@ import axios from "axios" import type { ModelInfo } from "@roo-code/types" -import { ApiHandlerOptions, azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults } from "../../shared/api" +import { + ApiHandlerOptions, + azureOpenAiDefaultApiVersion, + openAiModelInfoSaneDefaults, + ModelRecord, +} from "../../shared/api" import { XmlMatcher } from "../../utils/xml-matcher" @@ -367,14 +372,20 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl } } -export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record) { +export async function getOpenAiCompatibleModels( + baseUrl?: string, + apiKey?: string, + openAiHeaders?: Record, +): Promise { try { if (!baseUrl) { - return [] + console.warn("[getOpenAiModels] Base URL is missing, returning empty models.") + return {} } if (!URL.canParse(baseUrl)) { - return [] + console.warn(`[getOpenAiModels] Invalid Base URL: ${baseUrl}, returning empty models.`) + return {} } const config: Record = {} @@ -392,9 +403,21 @@ export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiH } const response = await axios.get(`${baseUrl}/models`, config) - const modelsArray = response.data?.data?.map((model: any) => model.id) || [] - return [...new Set(modelsArray)] + const modelsList = response.data?.data || response.data || [] + const modelsArray = modelsList.map((model: any) => model.id).filter(Boolean) || [] + + const modelRecord: ModelRecord = {} + for (const modelId of new Set(modelsArray)) { + modelRecord[modelId] = { + ...openAiModelInfoSaneDefaults, + description: `OpenAI-compatible: ${modelId} (${baseUrl})`, + inputPrice: undefined, + outputPrice: undefined, + } + } + return modelRecord } catch (error) { - return [] + console.error(`[getOpenAiModels] Error fetching OpenAI-compatible models from ${baseUrl}:`, error) + return {} } } diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index 5990193ecb..c852879b9c 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -2,9 +2,9 @@ import { Anthropic } from "@anthropic-ai/sdk" import * as vscode from "vscode" import type { ModelInfo } from "@roo-code/types" - +import { ApiHandlerOptions, ModelRecord, openAiModelInfoSaneDefaults } from "../../shared/api" import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "../../shared/vsCodeSelectorUtils" -import { ApiHandlerOptions, openAiModelInfoSaneDefaults } from "../../shared/api" +import { XmlMatcher } from "../../utils/xml-matcher" import { ApiStream } from "../transform/stream" import { convertToVsCodeLmMessages } from "../transform/vscode-lm-format" @@ -567,14 +567,32 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan // Static blacklist of VS Code Language Model IDs that should be excluded from the model list e.g. because they will never work const VSCODE_LM_STATIC_BLACKLIST: string[] = ["claude-3.7-sonnet", "claude-3.7-sonnet-thought"] -export async function getVsCodeLmModels() { +export async function getVsCodeLmModels(): Promise { try { - const models = (await vscode.lm.selectChatModels({})) || [] - return models.filter((model) => !VSCODE_LM_STATIC_BLACKLIST.includes(model.id)) + const availableModelsMeta = (await vscode.lm.selectChatModels({})) || [] + const filteredModelsMeta = availableModelsMeta.filter((model) => !VSCODE_LM_STATIC_BLACKLIST.includes(model.id)) + + const modelRecord: ModelRecord = {} + for (const modelMeta of filteredModelsMeta) { + // Construct a unique ID if modelMeta.id is not sufficiently unique or suitable as a key + const modelKey = + modelMeta.id || `${modelMeta.vendor}-${modelMeta.family}-${modelMeta.version}`.toLowerCase() + modelRecord[modelKey] = { + ...openAiModelInfoSaneDefaults, + // id: modelMeta.id, // ID is the key in ModelRecord + description: `VSCode LM: ${modelMeta.name || modelKey} (Vendor: ${modelMeta.vendor}, Family: ${modelMeta.family})`, + contextWindow: modelMeta.maxInputTokens, + supportsImages: false, // Default for VS Code LM, can be refined if API provides this + inputPrice: undefined, + outputPrice: undefined, + // Add other relevant properties from modelMeta if they map to ModelInfo + } + } + return modelRecord } catch (error) { console.error( `Error fetching VS Code LM models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, ) - return [] + return {} // Return empty ModelRecord on error } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e8e63cb3c6..ba5585e559 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -27,18 +27,14 @@ import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts" import { singleCompletionHandler } from "../../utils/single-completion-handler" import { searchCommits } from "../../utils/git" import { exportSettings, importSettings } from "../config/importExport" -import { getOpenAiModels } from "../../api/providers/openai" -import { getOllamaModels } from "../../api/providers/ollama" -import { getVsCodeLmModels } from "../../api/providers/vscode-lm" -import { getLmStudioModels } from "../../api/providers/lmstudio" import { openMention } from "../mentions" import { TelemetrySetting } from "../../shared/TelemetrySetting" import { getWorkspacePath } from "../../utils/path" import { Mode, defaultModeSlug } from "../../shared/modes" import { getModels, flushModels } from "../../api/providers/fetchers/modelCache" -import { GetModelsOptions } from "../../shared/api" import { generateSystemPrompt } from "./generateSystemPrompt" import { getCommand } from "../../utils/commands" +import { modelProviderStrategies } from "../../api/providers/fetchers" const ALLOWED_VSCODE_SETTINGS = new Set(["terminal.integrated.inheritEnv"]) @@ -296,104 +292,86 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We break case "requestRouterModels": const { apiConfiguration } = await provider.getState() + console.log("apiconfig1212", apiConfiguration, message.values) + const providerNameValue = message.values?.provider as string | undefined + const routerName = toRouterName(providerNameValue) + const flushCacheFirst = !!message.values?.flushCacheFirst - const routerModels: Partial> = { - openrouter: {}, - requesty: {}, - glama: {}, - unbound: {}, - litellm: {}, - } - - const safeGetModels = async (options: GetModelsOptions): Promise => { - try { - return await getModels(options) - } catch (error) { - console.error( - `Failed to fetch models in webviewMessageHandler requestRouterModels for ${options.provider}:`, - error, - ) - throw error // Re-throw to be caught by Promise.allSettled - } - } - - const modelFetchPromises: Array<{ key: RouterName; options: GetModelsOptions }> = [ - { key: "openrouter", options: { provider: "openrouter" } }, - { key: "requesty", options: { provider: "requesty", apiKey: apiConfiguration.requestyApiKey } }, - { key: "glama", options: { provider: "glama" } }, - { key: "unbound", options: { provider: "unbound", apiKey: apiConfiguration.unboundApiKey } }, - ] - - const litellmApiKey = apiConfiguration.litellmApiKey || message?.values?.litellmApiKey - const litellmBaseUrl = apiConfiguration.litellmBaseUrl || message?.values?.litellmBaseUrl - if (litellmApiKey && litellmBaseUrl) { - modelFetchPromises.push({ - key: "litellm", - options: { provider: "litellm", apiKey: litellmApiKey, baseUrl: litellmBaseUrl }, - }) - } - - const results = await Promise.allSettled( - modelFetchPromises.map(async ({ key, options }) => { - const models = await safeGetModels(options) - return { key, models } // key is RouterName here - }), + console.log( + `[requestRouterModels] Received request for ${routerName}. flushCacheFirst: ${flushCacheFirst}. Message values:`, + message.values, ) - const fetchedRouterModels: Partial> = { ...routerModels } + if (!providerNameValue || !routerName) { + provider.postMessageToWebview({ + type: "singleRouterModelFetchResponse", + success: false, + error: "Invalid or missing provider name for requestRouterModels", + values: { provider: providerNameValue || "unknown" }, + }) + break + } - results.forEach((result, index) => { - const routerName = modelFetchPromises[index].key // Get RouterName using index + const strategy = modelProviderStrategies[routerName] - if (result.status === "fulfilled") { - fetchedRouterModels[routerName] = result.value.models + if (!strategy || !strategy.getOptions) { + provider.postMessageToWebview({ + type: "singleRouterModelFetchResponse", + success: false, + error: `Unsupported provider or strategy misconfiguration: ${routerName}`, + values: { provider: routerName }, + }) + break + } + + const modelOptions = strategy.getOptions(apiConfiguration, message) + console.log(`[requestRouterModels] strategy.getOptions returned:`, modelOptions) + + if (!modelOptions) { + provider.postMessageToWebview({ + type: "singleRouterModelFetchResponse", + success: false, + error: `Required options missing for ${routerName} (e.g., API key/URL for LiteLLM/OpenAI-Compatible, or valid BaseURL for Ollama/LMStudio if passed via message.values)`, + values: { provider: routerName }, + }) + break + } + + try { + console.log( + `[requestRouterModels] In try block. routerName: ${routerName}, flushCacheFirst: ${flushCacheFirst}`, + ) + if (flushCacheFirst) { + console.log("[requestRouterModels] Condition for flushCacheFirst is TRUE. Calling flushModels.") + await flushModels(routerName) } else { - // Handle rejection: Post a specific error message for this provider - const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason) - console.error(`Error fetching models for ${routerName}:`, result.reason) - - fetchedRouterModels[routerName] = {} // Ensure it's an empty object in the main routerModels message + console.log("[requestRouterModels] Condition for flushCacheFirst is FALSE. Skipping flushModels.") + } + console.log("[requestRouterModels] About to call getModels with options:", modelOptions) + const models = await getModels(modelOptions) + console.log("models1212", models) + provider.postMessageToWebview({ + type: "singleRouterModelFetchResponse", + success: true, + values: { provider: routerName, models }, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + console.error(`Error fetching models for ${routerName} via requestRouterModels:`, error) + console.log(`[DEBUG] About to post error message for ${routerName}:`, errorMessage) + try { provider.postMessageToWebview({ type: "singleRouterModelFetchResponse", success: false, error: errorMessage, values: { provider: routerName }, }) + console.log(`[DEBUG] Error message posted successfully for ${routerName}`) + } catch (postError) { + console.error(`[DEBUG] Failed to post error message to webview:`, postError) } - }) - - provider.postMessageToWebview({ - type: "routerModels", - routerModels: fetchedRouterModels as Record, - }) - break - case "requestOpenAiModels": - if (message?.values?.baseUrl && message?.values?.apiKey) { - const openAiModels = await getOpenAiModels( - message?.values?.baseUrl, - message?.values?.apiKey, - message?.values?.openAiHeaders, - ) - - provider.postMessageToWebview({ type: "openAiModels", openAiModels }) } - - break - case "requestOllamaModels": - const ollamaModels = await getOllamaModels(message.text) - // TODO: Cache like we do for OpenRouter, etc? - provider.postMessageToWebview({ type: "ollamaModels", ollamaModels }) - break - case "requestLmStudioModels": - const lmStudioModels = await getLmStudioModels(message.text) - // TODO: Cache like we do for OpenRouter, etc? - provider.postMessageToWebview({ type: "lmStudioModels", lmStudioModels }) - break - case "requestVsCodeLmModels": - const vsCodeLmModels = await getVsCodeLmModels() - // TODO: Cache like we do for OpenRouter, etc? - provider.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels }) break case "openImage": openImage(message.text!) diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 31ac0611d7..32f0a99029 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -15,7 +15,6 @@ import { GitCommit } from "../utils/git" import { McpServer } from "./mcp" import { Mode } from "./modes" -import { RouterModels } from "./api" export interface LanguageModelChatSelector { vendor?: string @@ -40,7 +39,6 @@ export interface ExtensionMessage { | "enhancedPrompt" | "commitSearchResults" | "listApiConfig" - | "routerModels" | "openAiModels" | "ollamaModels" | "lmStudioModels" @@ -93,7 +91,6 @@ export interface ExtensionMessage { path?: string }> partialMessage?: ClineMessage - routerModels?: RouterModels openAiModels?: string[] ollamaModels?: string[] lmStudioModels?: string[] diff --git a/src/shared/api.ts b/src/shared/api.ts index 8e26523a07..a117266b00 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1972,7 +1972,17 @@ export const OPEN_ROUTER_REQUIRED_REASONING_BUDGET_MODELS = new Set([ "google/gemini-2.5-flash-preview-05-20:thinking", ]) -const routerNames = ["openrouter", "requesty", "glama", "unbound", "litellm"] as const +const routerNames = [ + "openrouter", + "requesty", + "glama", + "unbound", + "litellm", + "ollama", + "lmstudio", + "vscodelm", + "openai-compatible", +] as const export type RouterName = (typeof routerNames)[number] @@ -1988,8 +1998,6 @@ export function toRouterName(value?: string): RouterName { export type ModelRecord = Record -export type RouterModels = Record - export const shouldUseReasoningBudget = ({ model, settings, @@ -2045,3 +2053,13 @@ export type GetModelsOptions = | { provider: "requesty"; apiKey?: string } | { provider: "unbound"; apiKey?: string } | { provider: "litellm"; apiKey: string; baseUrl: string } + | { provider: "ollama"; baseUrl?: string } // Ollama might take an optional base URL + | { provider: "lmstudio"; baseUrl?: string } // LM Studio might take an optional base URL + | { provider: "vscodelm" } // VSCodeLM likely takes no specific options here + | { + provider: "openai-compatible" + baseUrl: string + apiKey?: string + headers?: Record + // We might not need openAiUseAzure and azureApiVersion here if getOpenAiModels can infer from baseUrl or they are passed to OpenAiHandler via general apiConfig + } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 49f6b45278..f4a50cb7f0 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -16,7 +16,6 @@ import { import { vscode } from "@src/utils/vscode" import { validateApiConfiguration } from "@src/utils/validate" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { useRouterModels } from "@src/components/ui/hooks/useRouterModels" import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel" import { useExtensionState } from "@src/context/ExtensionStateContext" import { filterProviders, filterModels } from "./utils/organizationFilters" @@ -75,6 +74,13 @@ const ApiOptions = ({ const { t } = useAppTranslation() const { organizationAllowList } = useExtensionState() + const refetchRouterModels = useCallback(() => { + vscode.postMessage({ + type: "flushRouterModels", + values: { provider: apiConfiguration.apiProvider }, + }) + }, [apiConfiguration.apiProvider]) + const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} return Object.entries(headers) @@ -82,22 +88,15 @@ const ApiOptions = ({ useEffect(() => { const propHeaders = apiConfiguration?.openAiHeaders || {} - if (JSON.stringify(customHeaders) !== JSON.stringify(Object.entries(propHeaders))) { setCustomHeaders(Object.entries(propHeaders)) } }, [apiConfiguration?.openAiHeaders, customHeaders]) - // Helper to convert array of tuples to object (filtering out empty keys). - - // Debounced effect to update the main configuration when local - // customHeaders state stabilizes. useDebounce( () => { const currentConfigHeaders = apiConfiguration?.openAiHeaders || {} const newHeadersObject = convertHeadersToObject(customHeaders) - - // Only update if the processed object is different from the current config. if (JSON.stringify(currentConfigHeaders) !== JSON.stringify(newHeadersObject)) { setApiConfigurationField("openAiHeaders", newHeadersObject) } @@ -125,61 +124,17 @@ const ApiOptions = ({ info: selectedModelInfo, } = useSelectedModel(apiConfiguration) - const { data: routerModels, refetch: refetchRouterModels } = useRouterModels() - - // Update `apiModelId` whenever `selectedModelId` changes. useEffect(() => { if (selectedModelId) { setApiConfigurationField("apiModelId", selectedModelId) } }, [selectedModelId, setApiConfigurationField]) - // Debounced refresh model updates, only executed 250ms after the user - // stops typing. - useDebounce( - () => { - if (selectedProvider === "openai") { - // Use our custom headers state to build the headers object. - const headerObject = convertHeadersToObject(customHeaders) - - vscode.postMessage({ - type: "requestOpenAiModels", - values: { - baseUrl: apiConfiguration?.openAiBaseUrl, - apiKey: apiConfiguration?.openAiApiKey, - customHeaders: {}, // Reserved for any additional headers - openAiHeaders: headerObject, - }, - }) - } else if (selectedProvider === "ollama") { - vscode.postMessage({ type: "requestOllamaModels", text: apiConfiguration?.ollamaBaseUrl }) - } else if (selectedProvider === "lmstudio") { - vscode.postMessage({ type: "requestLmStudioModels", text: apiConfiguration?.lmStudioBaseUrl }) - } else if (selectedProvider === "vscode-lm") { - vscode.postMessage({ type: "requestVsCodeLmModels" }) - } else if (selectedProvider === "litellm") { - vscode.postMessage({ type: "requestRouterModels" }) - } - }, - 250, - [ - selectedProvider, - apiConfiguration?.requestyApiKey, - apiConfiguration?.openAiBaseUrl, - apiConfiguration?.openAiApiKey, - apiConfiguration?.ollamaBaseUrl, - apiConfiguration?.lmStudioBaseUrl, - apiConfiguration?.litellmBaseUrl, - apiConfiguration?.litellmApiKey, - customHeaders, - ], - ) - useEffect(() => { - const apiValidationResult = validateApiConfiguration(apiConfiguration, routerModels, organizationAllowList) + const apiValidationResult = validateApiConfiguration(apiConfiguration, organizationAllowList) setErrorMessage(apiValidationResult) - }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage]) + }, [apiConfiguration, organizationAllowList, setErrorMessage]) const selectedProviderModels = useMemo(() => { const models = MODELS_BY_PROVIDER[selectedProvider] @@ -197,13 +152,6 @@ const ApiOptions = ({ const onProviderChange = useCallback( (value: ProviderName) => { - // It would be much easier to have a single attribute that stores - // the modelId, but we have a separate attribute for each of - // OpenRouter, Glama, Unbound, and Requesty. - // If you switch to one of these providers and the corresponding - // modelId is not set then you immediately end up in an error state. - // To address that we set the modelId to the default value for th - // provider if it's not already set. switch (value) { case "openrouter": if (!apiConfiguration.openRouterModelId) { @@ -231,7 +179,6 @@ const ApiOptions = ({ } break } - setApiConfigurationField("apiProvider", value) }, [ @@ -247,22 +194,10 @@ const ApiOptions = ({ const docs = useMemo(() => { const provider = PROVIDERS.find(({ value }) => value === selectedProvider) const name = provider?.label - - if (!name) { - return undefined - } - - // Get the URL slug - use custom mapping if available, otherwise use the provider key. - const slugs: Record = { - "openai-native": "openai", - openai: "openai-compatible", - } - + if (!name) return undefined + const slugs: Record = { "openai-native": "openai", openai: "openai-compatible" } const slug = slugs[selectedProvider] || selectedProvider - return { - url: buildDocLink(`providers/${slug}`, "provider_docs"), - name, - } + return { url: buildDocLink(`providers/${slug}`, "provider_docs"), name } }, [selectedProvider]) return ( @@ -298,7 +233,6 @@ const ApiOptions = ({ @@ -320,7 +253,6 @@ const ApiOptions = ({ @@ -330,7 +262,6 @@ const ApiOptions = ({ )} @@ -427,7 +358,6 @@ const ApiOptions = ({ onValueChange={(value) => { setApiConfigurationField("apiModelId", value) - // Clear custom ARN if not using custom ARN option. if (value !== "custom-arn" && selectedProvider === "bedrock") { setApiConfigurationField("awsCustomArn", "") } diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index 906b98e47e..3bb356f5f2 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -54,30 +54,55 @@ export const ModelPicker = ({ const [open, setOpen] = useState(false) const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false) - const isInitialized = useRef(false) const searchInputRef = useRef(null) - const modelIds = useMemo(() => { - const filteredModels = filterModels(models, apiConfiguration.apiProvider, organizationAllowList) + const currentConfiguredModelId = apiConfiguration[modelIdKey] + const modelIdsForDropdown = useMemo(() => { + const filteredModels = filterModels(models, apiConfiguration.apiProvider, organizationAllowList) return Object.keys(filteredModels ?? {}).sort((a, b) => a.localeCompare(b)) }, [models, apiConfiguration.apiProvider, organizationAllowList]) - const { id: selectedModelId, info: selectedModelInfo } = useSelectedModel(apiConfiguration) + const { id: selectedModelIdForInfo, info: selectedModelInfo } = useSelectedModel(apiConfiguration) - const [searchValue, setSearchValue] = useState(selectedModelId || "") + const [searchValue, setSearchValue] = useState(currentConfiguredModelId || "") + + useEffect(() => { + const currentIdInSettings = apiConfiguration[modelIdKey] + + if (!models || Object.keys(models).length === 0) { + if (currentIdInSettings !== undefined) { + setApiConfigurationField(modelIdKey, undefined) + } + if (searchValue !== "") setSearchValue("") + } else { + const availableIds = Object.keys(models) + let newIdToSet: string | undefined = undefined + + if (currentIdInSettings && availableIds.includes(currentIdInSettings)) { + newIdToSet = currentIdInSettings + } else if (availableIds.includes(defaultModelId)) { + newIdToSet = defaultModelId + } else if (availableIds.length > 0) { + newIdToSet = availableIds[0] + } + + if (currentIdInSettings !== newIdToSet) { + setApiConfigurationField(modelIdKey, newIdToSet) + } + const targetSearchValue = newIdToSet || "" + if (searchValue !== targetSearchValue) setSearchValue(targetSearchValue) + } + }, [models, apiConfiguration, searchValue, defaultModelId, modelIdKey, setApiConfigurationField]) const onSelect = useCallback( (modelId: string) => { if (!modelId) { return } - setOpen(false) setApiConfigurationField(modelIdKey, modelId) - - // Delay to ensure the popover is closed before setting the search value. - setTimeout(() => setSearchValue(modelId), 100) + setSearchValue(modelId) }, [modelIdKey, setApiConfigurationField], ) @@ -85,14 +110,11 @@ export const ModelPicker = ({ const onOpenChange = useCallback( (open: boolean) => { setOpen(open) - - // Abandon the current search if the popover is closed. if (!open) { - // Delay to ensure the popover is closed before setting the search value. - setTimeout(() => setSearchValue(selectedModelId), 100) + setSearchValue(apiConfiguration[modelIdKey] || "") } }, - [selectedModelId], + [apiConfiguration, modelIdKey], ) const onClearSearch = useCallback(() => { @@ -100,15 +122,6 @@ export const ModelPicker = ({ searchInputRef.current?.focus() }, []) - useEffect(() => { - if (!selectedModelId && !isInitialized.current) { - const initialValue = modelIds.includes(selectedModelId) ? selectedModelId : defaultModelId - setApiConfigurationField(modelIdKey, initialValue) - } - - isInitialized.current = true - }, [modelIds, setApiConfigurationField, modelIdKey, selectedModelId, defaultModelId]) - return ( <>
@@ -120,7 +133,7 @@ export const ModelPicker = ({ role="combobox" aria-expanded={open} className="w-full justify-between"> -
{selectedModelId ?? t("settings:common.select")}
+
{currentConfiguredModelId ?? t("settings:common.select")}
@@ -153,20 +166,20 @@ export const ModelPicker = ({ )} - {modelIds.map((model) => ( + {modelIdsForDropdown.map((model) => ( {model} ))} - {searchValue && !modelIds.includes(searchValue) && ( + {searchValue && !modelIdsForDropdown.includes(searchValue) && (
{t("settings:modelPicker.useCustomModel", { modelId: searchValue })} @@ -177,10 +190,10 @@ export const ModelPicker = ({
- {selectedModelId && selectedModelInfo && ( + {selectedModelIdForInfo && selectedModelInfo && ( void - routerModels?: RouterModels uriScheme?: string organizationAllowList: OrganizationAllowList } -export const Glama = ({ - apiConfiguration, - setApiConfigurationField, - routerModels, - uriScheme, - organizationAllowList, -}: GlamaProps) => { +export const Glama = ({ apiConfiguration, setApiConfigurationField, uriScheme, organizationAllowList }: GlamaProps) => { const { t } = useAppTranslation() + const { models: glamaModelsData, isLoading: isLoadingModels, error: modelsError } = useProviderModels("glama") + const handleInputChange = useCallback( ( field: K, @@ -40,6 +35,14 @@ export const Glama = ({ [setApiConfigurationField], ) + if (isLoadingModels) { + return

{t("settings:providers.refreshModels.loading")}

+ } + + if (modelsError) { + return

{t("settings:providers.refreshModels.error")}

+ } + return ( <> { const { t } = useAppTranslation() - const { routerModels } = useExtensionState() - const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") - const [refreshError, setRefreshError] = useState() - const litellmErrorJustReceived = useRef(false) - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - const message = event.data - if (message.type === "singleRouterModelFetchResponse" && !message.success) { - const providerName = message.values?.provider as RouterName - if (providerName === "litellm") { - litellmErrorJustReceived.current = true - setRefreshStatus("error") - setRefreshError(message.error) - } - } else if (message.type === "routerModels") { - // If we were loading and no specific error for litellm was just received, mark as success. - // The ModelPicker will show available models or "no models found". - if (refreshStatus === "loading") { - if (!litellmErrorJustReceived.current) { - setRefreshStatus("success") - } - // If litellmErrorJustReceived.current is true, status is already (or will be) "error". - } - } - } + const providerModelsOptions = useMemo( + () => ({ + flushCacheFirst: true, + litellmApiKey: apiConfiguration?.litellmApiKey, + litellmBaseUrl: apiConfiguration?.litellmBaseUrl, + }), + [apiConfiguration?.litellmApiKey, apiConfiguration?.litellmBaseUrl], + ) - window.addEventListener("message", handleMessage) - return () => { - window.removeEventListener("message", handleMessage) - } - }, [refreshStatus, refreshError, setRefreshStatus, setRefreshError]) + const { + models: litellmModelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useProviderModels("litellm", providerModelsOptions) + console.log("litellmModelsData1212", litellmModelsData, isLoadingModels, modelsError) const handleInputChange = useCallback( ( @@ -66,22 +47,6 @@ export const LiteLLM = ({ apiConfiguration, setApiConfigurationField, organizati [setApiConfigurationField], ) - const handleRefreshModels = useCallback(() => { - litellmErrorJustReceived.current = false // Reset flag on new refresh action - setRefreshStatus("loading") - setRefreshError(undefined) - - const key = apiConfiguration.litellmApiKey - const url = apiConfiguration.litellmBaseUrl - - if (!key || !url) { - setRefreshStatus("error") - setRefreshError(t("settings:providers.refreshModels.missingConfig")) - return - } - vscode.postMessage({ type: "requestRouterModels", values: { litellmApiKey: key, litellmBaseUrl: url } }) - }, [apiConfiguration, setRefreshStatus, setRefreshError, t]) - return ( <> - - {refreshStatus === "loading" && ( -
- {t("settings:providers.refreshModels.loading")} -
+ {isLoadingModels &&

{t("settings:providers.refreshModels.loading")}

} + {modelsError && ( +

{t("settings:providers.refreshModels.error")}

)} - {refreshStatus === "success" && ( -
{t("settings:providers.refreshModels.success")}
- )} - {refreshStatus === "error" && ( -
- {refreshError || t("settings:providers.refreshModels.error")} -
+ {!isLoadingModels && !modelsError && litellmModelsData && Object.keys(litellmModelsData).length === 0 && ( +

{t("settings:common.noModelsFound")}

)} + { const { t } = useAppTranslation() - const [ollamaModels, setOllamaModels] = useState([]) + const { models: ollamaModelsData, isLoading: isLoadingModels, error: modelsError } = useProviderModels("ollama") const handleInputChange = useCallback( ( @@ -31,20 +28,19 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro [setApiConfigurationField], ) - const onMessage = useCallback((event: MessageEvent) => { - const message: ExtensionMessage = event.data + if (isLoadingModels) { + return
{t("settings:common.loadingModels")}
+ } - switch (message.type) { - case "ollamaModels": - { - const newModels = message.ollamaModels ?? [] - setOllamaModels(newModels) - } - break - } - }, []) + if (modelsError) { + return ( +
+ {t("settings:common.errorModels")}: {modelsError} +
+ ) + } - useEvent("message", onMessage) + const availableModelIds = ollamaModelsData ? Object.keys(ollamaModelsData) : [] return ( <> @@ -63,17 +59,27 @@ export const Ollama = ({ apiConfiguration, setApiConfigurationField }: OllamaPro className="w-full">
- {ollamaModels.length > 0 && ( + + {!isLoadingModels && !modelsError && availableModelIds.length === 0 && ( +
+ {t("settings:common.noModelsFound")} +
+ )} + + {availableModelIds.length > 0 && ( - {ollamaModels.map((model) => ( - - {model} + {availableModelIds.map((modelId) => ( + + {modelId} ))} diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index a68f78a051..0d2cfaa2fe 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -1,15 +1,14 @@ import { useState, useCallback, useEffect } from "react" -import { useEvent } from "react-use" import { Checkbox } from "vscrui" import { VSCodeButton, VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import type { ProviderSettings, ModelInfo, ReasoningEffort, OrganizationAllowList } from "@roo-code/types" +import type { ProviderSettings, ReasoningEffort, OrganizationAllowList } from "@roo-code/types" import { azureOpenAiDefaultApiVersion, openAiModelInfoSaneDefaults } from "@roo/api" -import { ExtensionMessage } from "@roo/ExtensionMessage" import { useAppTranslation } from "@src/i18n/TranslationContext" import { Button } from "@src/components/ui" +import { useProviderModels } from "@src/components/ui/hooks/useProviderModels" import { convertHeadersToObject } from "../utils/headers" import { inputEventTransform, noTransform } from "../transforms" @@ -33,7 +32,11 @@ export const OpenAICompatible = ({ const [azureApiVersionSelected, setAzureApiVersionSelected] = useState(!!apiConfiguration?.azureApiVersion) const [openAiLegacyFormatSelected, setOpenAiLegacyFormatSelected] = useState(!!apiConfiguration?.openAiLegacyFormat) - const [openAiModels, setOpenAiModels] = useState | null>(null) + const { + models: openAiCompatibleModels, + isLoading: isLoadingOpenAiCompatibleModels, + error: openAiCompatibleModelsError, + } = useProviderModels("openai-compatible") const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -97,20 +100,6 @@ export const OpenAICompatible = ({ [setApiConfigurationField], ) - const onMessage = useCallback((event: MessageEvent) => { - const message: ExtensionMessage = event.data - - switch (message.type) { - case "openAiModels": { - const updatedModels = message.openAiModels ?? [] - setOpenAiModels(Object.fromEntries(updatedModels.map((item) => [item, openAiModelInfoSaneDefaults]))) - break - } - } - }, []) - - useEvent("message", onMessage) - return ( <> + {isLoadingOpenAiCompatibleModels &&

{t("settings:providers.refreshModels.loading")}

} + {openAiCompatibleModelsError && ( +

{t("settings:providers.refreshModels.error")}

+ )} void - routerModels?: RouterModels selectedModelId: string uriScheme: string | undefined fromWelcomeView?: boolean @@ -35,7 +34,6 @@ type OpenRouterProps = { export const OpenRouter = ({ apiConfiguration, setApiConfigurationField, - routerModels, selectedModelId, uriScheme, fromWelcomeView, @@ -43,6 +41,12 @@ export const OpenRouter = ({ }: OpenRouterProps) => { const { t } = useAppTranslation() + const { + models: openRouterModelsData, + isLoading: isLoadingModels, + error: modelsError, + } = useProviderModels("openrouter") + const [openRouterBaseUrlSelected, setOpenRouterBaseUrlSelected] = useState(!!apiConfiguration?.openRouterBaseUrl) const handleInputChange = useCallback( @@ -56,14 +60,26 @@ export const OpenRouter = ({ [setApiConfigurationField], ) - const { data: openRouterModelProviders } = useOpenRouterModelProviders(apiConfiguration?.openRouterModelId, { + const { data: openRouterModelProviders } = useOpenRouterModelProviders(selectedModelId, { enabled: - !!apiConfiguration?.openRouterModelId && - routerModels?.openrouter && - Object.keys(routerModels.openrouter).length > 1 && - apiConfiguration.openRouterModelId in routerModels.openrouter, + !!selectedModelId && + !!openRouterModelsData && + Object.keys(openRouterModelsData).length > 0 && + selectedModelId in openRouterModelsData, }) + if (isLoadingModels) { + return
{t("settings:common.loadingModels")}
+ } + + if (modelsError) { + return ( +
+ {t("settings:common.errorModels")}: {modelsError} +
+ ) + } + return ( <> , + _blank: ( + + ), }} /> @@ -130,7 +152,7 @@ export const OpenRouter = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} defaultModelId={openRouterDefaultModelId} - models={routerModels?.openrouter ?? {}} + models={openRouterModelsData ?? {}} modelIdKey="openRouterModelId" serviceName="OpenRouter" serviceUrl="https://openrouter.ai/models" @@ -142,7 +164,10 @@ export const OpenRouter = ({ - +
@@ -165,7 +190,10 @@ export const OpenRouter = ({
{t("settings:providers.openRouter.providerRouting.description")}{" "} - + {t("settings:providers.openRouter.providerRouting.learnMore")}.
diff --git a/webview-ui/src/components/settings/providers/Requesty.tsx b/webview-ui/src/components/settings/providers/Requesty.tsx index dd675afc06..91baeb04a7 100644 --- a/webview-ui/src/components/settings/providers/Requesty.tsx +++ b/webview-ui/src/components/settings/providers/Requesty.tsx @@ -1,14 +1,13 @@ -import { useCallback, useState } from "react" +import { useCallback } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" -import { RouterModels, requestyDefaultModelId } from "@roo/api" +import { requestyDefaultModelId } from "@roo/api" -import { vscode } from "@src/utils/vscode" import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" -import { Button } from "@src/components/ui" +import { useProviderModels } from "../../ui/hooks/useProviderModels" import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" @@ -17,21 +16,14 @@ import { RequestyBalanceDisplay } from "./RequestyBalanceDisplay" type RequestyProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void - routerModels?: RouterModels refetchRouterModels: () => void organizationAllowList: OrganizationAllowList } -export const Requesty = ({ - apiConfiguration, - setApiConfigurationField, - routerModels, - refetchRouterModels, - organizationAllowList, -}: RequestyProps) => { +export const Requesty = ({ apiConfiguration, setApiConfigurationField, organizationAllowList }: RequestyProps) => { const { t } = useAppTranslation() - const [didRefetch, setDidRefetch] = useState() + const { models: requestyModelsData, isLoading: isLoadingModels, error: modelsError } = useProviderModels("requesty") const handleInputChange = useCallback( ( @@ -44,6 +36,18 @@ export const Requesty = ({ [setApiConfigurationField], ) + if (isLoadingModels) { + return
{t("settings:common.loadingModels")}
+ } + + if (modelsError) { + return ( +
+ {t("settings:common.errorModels")}: {modelsError} +
+ ) + } + return ( <> )} - - {didRefetch && ( -
- {t("settings:providers.refreshModels.hint")} -
- )} void - routerModels?: RouterModels organizationAllowList: OrganizationAllowList } -export const Unbound = ({ - apiConfiguration, - setApiConfigurationField, - routerModels, - organizationAllowList, -}: UnboundProps) => { +export const Unbound = ({ apiConfiguration, setApiConfigurationField, organizationAllowList }: UnboundProps) => { const { t } = useAppTranslation() - const [didRefetch, setDidRefetch] = useState() - const [isInvalidKey, setIsInvalidKey] = useState(false) - const queryClient = useQueryClient() - // Add refs to store timer IDs - const didRefetchTimerRef = useRef() + const { models: unboundModelsData, isLoading: isLoadingModels, error: modelsError } = useProviderModels("unbound") + + const [isInvalidKeyFeedback, setIsInvalidKeyFeedback] = useState(false) const invalidKeyTimerRef = useRef() const handleInputChange = useCallback( @@ -43,93 +32,42 @@ export const Unbound = ({ ) => (event: E | Event) => { setApiConfigurationField(field, transform(event as E)) + if (field === "unboundApiKey") { + setIsInvalidKeyFeedback(false) + if (invalidKeyTimerRef.current) clearTimeout(invalidKeyTimerRef.current) + } }, [setApiConfigurationField], ) - const saveConfiguration = useCallback(async () => { - vscode.postMessage({ - type: "upsertApiConfiguration", - text: "default", - apiConfiguration: apiConfiguration, - }) - - const waitForStateUpdate = new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - window.removeEventListener("message", messageHandler) - reject(new Error("Timeout waiting for state update")) - }, 10000) // 10 second timeout - - const messageHandler = (event: MessageEvent) => { - const message = event.data - if (message.type === "state") { - clearTimeout(timeoutId) - window.removeEventListener("message", messageHandler) - resolve() - } - } - window.addEventListener("message", messageHandler) - }) - - try { - await waitForStateUpdate - } catch (error) { - console.error("Failed to save configuration:", error) - } - }, [apiConfiguration]) - - const requestModels = useCallback(async () => { - vscode.postMessage({ type: "flushRouterModels", text: "unbound" }) - - const modelsPromise = new Promise((resolve) => { - const messageHandler = (event: MessageEvent) => { - const message = event.data - if (message.type === "routerModels") { - window.removeEventListener("message", messageHandler) - resolve() - } - } - window.addEventListener("message", messageHandler) - }) - - vscode.postMessage({ type: "requestRouterModels" }) - - await modelsPromise - - await queryClient.invalidateQueries({ queryKey: ["routerModels"] }) - - // After refreshing models, check if current model is in the updated list - // If not, select the first available model - const updatedModels = queryClient.getQueryData<{ unbound: RouterModels }>(["routerModels"])?.unbound - if (updatedModels && Object.keys(updatedModels).length > 0) { - const currentModelId = apiConfiguration?.unboundModelId - const modelExists = currentModelId && Object.prototype.hasOwnProperty.call(updatedModels, currentModelId) - - if (!currentModelId || !modelExists) { - const firstAvailableModelId = Object.keys(updatedModels)[0] - setApiConfigurationField("unboundModelId", firstAvailableModelId) - } - } - - if (!updatedModels || Object.keys(updatedModels).includes("error")) { - return false + useEffect(() => { + if ( + modelsError && + (modelsError.includes("401") || + modelsError.toLowerCase().includes("unauthorized") || + modelsError.toLowerCase().includes("invalid api key")) + ) { + setIsInvalidKeyFeedback(true) + invalidKeyTimerRef.current = setTimeout(() => setIsInvalidKeyFeedback(false), 5000) } else { - return true + setIsInvalidKeyFeedback(false) } - }, [queryClient, apiConfiguration, setApiConfigurationField]) - - const handleRefresh = useCallback(async () => { - await saveConfiguration() - const requestModelsResult = await requestModels() - - if (requestModelsResult) { - setDidRefetch(true) - didRefetchTimerRef.current = setTimeout(() => setDidRefetch(false), 3000) - } else { - setIsInvalidKey(true) - invalidKeyTimerRef.current = setTimeout(() => setIsInvalidKey(false), 3000) + return () => { + if (invalidKeyTimerRef.current) clearTimeout(invalidKeyTimerRef.current) } - }, [saveConfiguration, requestModels]) + }, [modelsError]) + + if (isLoadingModels && !unboundModelsData) { + return
{t("settings:common.loadingModels")}
+ } + + if (modelsError && !isInvalidKeyFeedback) { + return ( +
+ {t("settings:common.errorModels")}: {modelsError} +
+ ) + } return ( <> @@ -149,28 +87,15 @@ export const Unbound = ({ {t("settings:providers.getUnboundApiKey")} )} -
- -
- {didRefetch && ( -
- {t("settings:providers.unboundRefreshModelsSuccess")} -
- )} - {isInvalidKey && ( -
+ {isInvalidKeyFeedback && ( +
{t("settings:providers.unboundInvalidApiKey")}
)} void @@ -19,69 +15,90 @@ type VSCodeLMProps = { export const VSCodeLM = ({ apiConfiguration, setApiConfigurationField }: VSCodeLMProps) => { const { t } = useAppTranslation() - const [vsCodeLmModels, setVsCodeLmModels] = useState([]) + const { models: vsCodeLmModelsData, isLoading: isLoadingModels, error: modelsError } = useProviderModels("vscodelm") - const handleInputChange = useCallback( - ( - field: K, - transform: (event: E) => ProviderSettings[K] = inputEventTransform, - ) => - (event: E | Event) => { - setApiConfigurationField(field, transform(event as E)) - }, - [setApiConfigurationField], + const handleModelSelectionChange = useCallback( + (selectedModelId: string) => { + const modelInfo = vsCodeLmModelsData?.[selectedModelId] + + let selector: LanguageModelChatSelector = { id: selectedModelId } + + if (modelInfo && typeof modelInfo.description === "string") { + const vendorMatch = modelInfo.description.match(/Vendor: ([^,]+)/) + const familyMatch = modelInfo.description.match(/Family: ([^,)]+)/) + if (vendorMatch?.[1] && familyMatch?.[1]) { + selector = { vendor: vendorMatch[1].trim(), family: familyMatch[1].trim(), id: selectedModelId } + } else if (selectedModelId.includes("/")) { + const parts = selectedModelId.split("/") + if (parts.length >= 2) { + selector = { vendor: parts[0], family: parts[1], id: selectedModelId } + if (parts.length >= 3) selector.version = parts[2] + } + } + } + + setApiConfigurationField("vsCodeLmModelSelector", selector) + }, + [setApiConfigurationField, vsCodeLmModelsData], ) - const onMessage = useCallback((event: MessageEvent) => { - const message: ExtensionMessage = event.data + if (isLoadingModels) { + return
{t("settings:common.loadingModels")}
+ } - switch (message.type) { - case "vsCodeLmModels": - { - const newModels = message.vsCodeLmModels ?? [] - setVsCodeLmModels(newModels) - } - break + if (modelsError) { + return ( +
+ {t("settings:common.errorModels")}: {modelsError} +
+ ) + } + + const availableModels = vsCodeLmModelsData ? Object.entries(vsCodeLmModelsData) : [] + + let currentSelectedValue = "" + const currentSelector = apiConfiguration?.vsCodeLmModelSelector + if (currentSelector) { + if (currentSelector.id && availableModels.some(([id]) => id === currentSelector.id)) { + currentSelectedValue = currentSelector.id + } else if (currentSelector.vendor && currentSelector.family) { + const constructedId = `${currentSelector.vendor}/${currentSelector.family}`.toLowerCase() + if (availableModels.some(([id]) => id.startsWith(constructedId))) { + currentSelectedValue = availableModels.find(([id]) => id.startsWith(constructedId))?.[0] || "" + } } - }, []) - - useEvent("message", onMessage) + } + if (!currentSelectedValue && availableModels.length > 0) { + // If still no value and models exist, maybe pick the first one or default? + // For now, leave as empty string if no match from config. + } return ( <>
- {vsCodeLmModels.length > 0 ? ( - - {vsCodeLmModels.map((model) => ( - - {`${model.vendor} - ${model.family}`} + {availableModels.map(([id, modelInfo]) => ( + + {modelInfo?.description || id} ))} ) : (
- {t("settings:providers.vscodeLmDescription")} + {isLoadingModels + ? t("settings:common.loadingModels") + : t("settings:providers.vscodeLmDescription")}
)}
-
{t("settings:providers.vscodeLmWarning")}
+
{t("settings:providers.vscodeLmWarning")}
) } diff --git a/webview-ui/src/components/ui/hooks/useProviderModels.ts b/webview-ui/src/components/ui/hooks/useProviderModels.ts new file mode 100644 index 0000000000..c78e5a9497 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/useProviderModels.ts @@ -0,0 +1,180 @@ +import { useQuery, useQueryClient, QueryKey } from "@tanstack/react-query" +import { useEffect, useMemo, useRef, useState } from "react" + +import { RouterName, ModelRecord } from "@roo/api" +import { ExtensionMessage } from "@roo/ExtensionMessage" +import { vscode } from "@src/utils/vscode" +import { useDebounceEffect } from "@src/utils/useDebounceEffect" + +// --- START: Type definitions for provider-specific params --- +// Inspired by GetModelsOptions from src/shared/api.ts +// These are the *additional* params a provider might need, sent from the UI. +export type ProviderSpecificParamsMap = { + openrouter: Record + glama: Record + requesty: { requestyApiKey?: string } + unbound: { unboundApiKey?: string } + litellm: { litellmApiKey?: string; litellmBaseUrl?: string } + ollama: { baseUrl?: string } + lmstudio: { baseUrl?: string } + vscodelm: Record + "openai-compatible": { + baseUrl: string + apiKey?: string + openAiHeaders?: Record + } +} + +// The options object for useProviderModels hook and fetchProviderModels function +export type UseProviderModelsOptions

= { + flushCacheFirst?: boolean +} & ProviderSpecificParamsMap[P] +// --- END: Type definitions for provider-specific params --- + +interface UseProviderModelsResult { + models?: ModelRecord + isLoading: boolean + error?: string + refetch: () => void +} + +const DEBOUNCE_DELAY = 250 +const REQUEST_TIMEOUT = 15000 + +const fetchProviderModels = async

( + providerName: P, + options?: UseProviderModelsOptions

, +): Promise => { + // Use AbortController for better cleanup + const abortController = new AbortController() + + return new Promise((resolve, reject) => { + let handler: ((event: MessageEvent) => void) | null = null + let timeoutId: NodeJS.Timeout | null = null + + const cleanup = () => { + if (handler) { + window.removeEventListener("message", handler) + handler = null + } + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = null + } + } + + // Set up timeout + timeoutId = setTimeout(() => { + cleanup() + reject(new Error(`Request for ${providerName} models timed out`)) + }, REQUEST_TIMEOUT) + + // Set up message handler + handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if (message.type === "singleRouterModelFetchResponse" && message.values?.provider === providerName) { + cleanup() + if (message.success && message.values?.models) { + resolve(message.values.models as ModelRecord) + } else { + reject(new Error(message.error || `Failed to fetch models for ${providerName}`)) + } + } + } + + // Listen for abort signal + abortController.signal.addEventListener("abort", () => { + cleanup() + reject(new Error("Request was aborted")) + }) + + window.addEventListener("message", handler) + + const { flushCacheFirst = true, ...providerParams } = options || {} + vscode.postMessage({ + type: "requestRouterModels", + values: { provider: providerName, flushCacheFirst, ...providerParams }, + }) + }) +} + +export const useProviderModels =

( + providerName: P, + options?: UseProviderModelsOptions

, +): UseProviderModelsResult => { + const queryClient = useQueryClient() + + // Track if we're currently debouncing + const debouncingRef = useRef(false) + const [debouncedReady, setDebouncedReady] = useState(false) + + // Debounce the options to avoid rapid re-fetches + const [debouncedOptions, setDebouncedOptions] = useState(options) + + // Extract relevant options for debouncing (exclude flushCacheFirst) + const { flushCacheFirst: _flush, ...relevantOptions } = options || {} + const optionsKey = JSON.stringify({ providerName, ...relevantOptions }) + + // Reset debouncing state when options change + useEffect(() => { + debouncingRef.current = true + setDebouncedReady(false) + }, [optionsKey]) + + // Debounce the options update + useDebounceEffect( + () => { + setDebouncedOptions(options) + debouncingRef.current = false + setDebouncedReady(true) + }, + DEBOUNCE_DELAY, + [options, providerName], + ) + + // Create a stable query key based on debounced options + const queryKey: QueryKey = useMemo( + () => ["providerModels", providerName, debouncedOptions || {}], + [providerName, debouncedOptions], + ) + + // Query for provider models + const { + data, + isLoading: isQueryLoading, + error: queryError, + refetch, + } = useQuery({ + queryKey, + queryFn: () => fetchProviderModels(providerName, debouncedOptions), + enabled: !!providerName && debouncedReady, + retry: false, + staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes + }) + + // Listen for cache invalidation messages + useEffect(() => { + const handler = (event: MessageEvent) => { + const message: ExtensionMessage = event.data + if ((message.type as any) === "flushRouterModels" && message?.values?.provider === providerName) { + queryClient.invalidateQueries({ queryKey }) + } + } + + window.addEventListener("message", handler) + return () => window.removeEventListener("message", handler) + }, [providerName, queryClient, queryKey]) + + // Combine debouncing and query loading states + const isLoading = debouncingRef.current || isQueryLoading + + // Clear error when in loading state + const error = isLoading ? undefined : queryError?.message + + return { + models: data, + isLoading, + error, + refetch, + } +} diff --git a/webview-ui/src/components/ui/hooks/useRouterModels.ts b/webview-ui/src/components/ui/hooks/useRouterModels.ts deleted file mode 100644 index 0ca68cc27a..0000000000 --- a/webview-ui/src/components/ui/hooks/useRouterModels.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useQuery } from "@tanstack/react-query" - -import { RouterModels } from "@roo/api" -import { ExtensionMessage } from "@roo/ExtensionMessage" - -import { vscode } from "@src/utils/vscode" - -const getRouterModels = async () => - new Promise((resolve, reject) => { - const cleanup = () => { - window.removeEventListener("message", handler) - } - - const timeout = setTimeout(() => { - cleanup() - reject(new Error("Router models request timed out")) - }, 10000) - - const handler = (event: MessageEvent) => { - const message: ExtensionMessage = event.data - - if (message.type === "routerModels") { - clearTimeout(timeout) - cleanup() - - if (message.routerModels) { - resolve(message.routerModels) - } else { - reject(new Error("No router models in response")) - } - } - } - - window.addEventListener("message", handler) - vscode.postMessage({ type: "requestRouterModels" }) - }) - -export const useRouterModels = () => useQuery({ queryKey: ["routerModels"], queryFn: getRouterModels }) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index f656c702dd..a9e2b33547 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -1,7 +1,8 @@ import type { ProviderName, ProviderSettings, ModelInfo } from "@roo-code/types" import { - RouterModels, + RouterName, + ModelRecord, anthropicDefaultModelId, anthropicModels, bedrockDefaultModelId, @@ -25,186 +26,199 @@ import { chutesDefaultModelId, vscodeLlmModels, vscodeLlmDefaultModelId, + VscodeLlmModelId, openRouterDefaultModelId, requestyDefaultModelId, glamaDefaultModelId, unboundDefaultModelId, litellmDefaultModelId, + isRouterName, } from "@roo/api" -import { useRouterModels } from "./useRouterModels" +import { useProviderModels } from "./useProviderModels" import { useOpenRouterModelProviders } from "./useOpenRouterModelProviders" export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const provider = apiConfiguration?.apiProvider || "anthropic" const openRouterModelId = provider === "openrouter" ? apiConfiguration?.openRouterModelId : undefined - const routerModels = useRouterModels() + const currentProviderIsRouter = isRouterName(provider) + + const { + models: routerProviderModels, + isLoading: isRouterProviderLoading, + error: routerProviderError, + } = useProviderModels(currentProviderIsRouter ? (provider as RouterName) : undefined) + const openRouterModelProviders = useOpenRouterModelProviders(openRouterModelId) - const { id, info } = - apiConfiguration && - typeof routerModels.data !== "undefined" && - typeof openRouterModelProviders.data !== "undefined" - ? getSelectedModel({ - provider, - apiConfiguration, - routerModels: routerModels.data, - openRouterModelProviders: openRouterModelProviders.data, - }) - : { id: anthropicDefaultModelId, info: undefined } + const { id, info } = (() => { + if (!apiConfiguration) { + return { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] } + } + if (currentProviderIsRouter && (isRouterProviderLoading || routerProviderError)) { + return { id: isRouterProviderLoading ? "loading..." : "error", info: undefined } + } + if (provider === "openrouter" && (openRouterModelProviders.isLoading || openRouterModelProviders.isError)) { + return { id: openRouterModelProviders.isLoading ? "loading..." : "error", info: undefined } + } + + return getSelectedModel({ + provider, + apiConfiguration, + providerModelRecord: currentProviderIsRouter ? routerProviderModels : undefined, + openRouterModelProviders: openRouterModelProviders.data, + }) + })() return { provider, id, info, - isLoading: routerModels.isLoading || openRouterModelProviders.isLoading, - isError: routerModels.isError || openRouterModelProviders.isError, + isLoading: + (currentProviderIsRouter && isRouterProviderLoading) || + (provider === "openrouter" && openRouterModelProviders.isLoading), + isError: + !!(currentProviderIsRouter && routerProviderError) || + (provider === "openrouter" && openRouterModelProviders.isError), } } function getSelectedModel({ provider, apiConfiguration, - routerModels, + providerModelRecord, openRouterModelProviders, }: { provider: ProviderName apiConfiguration: ProviderSettings - routerModels: RouterModels - openRouterModelProviders: Record -}): { id: string; info: ModelInfo } { + providerModelRecord?: ModelRecord + openRouterModelProviders?: Record +}): { id: string; info?: ModelInfo } { switch (provider) { case "openrouter": { const id = apiConfiguration.openRouterModelId ?? openRouterDefaultModelId - let info = routerModels.openrouter[id] + let modelInfo = providerModelRecord?.[id] const specificProvider = apiConfiguration.openRouterSpecificProvider - if (specificProvider && openRouterModelProviders[specificProvider]) { - // Overwrite the info with the specific provider info. Some - // fields are missing the model info for `openRouterModelProviders` - // so we need to merge the two. - info = info - ? { ...info, ...openRouterModelProviders[specificProvider] } + if (specificProvider && openRouterModelProviders?.[specificProvider]) { + modelInfo = modelInfo + ? { ...modelInfo, ...openRouterModelProviders[specificProvider] } : openRouterModelProviders[specificProvider] } - return info - ? { id, info } - : { id: openRouterDefaultModelId, info: routerModels.openrouter[openRouterDefaultModelId] } + return { id, info: modelInfo || providerModelRecord?.[openRouterDefaultModelId] } } case "requesty": { const id = apiConfiguration.requestyModelId ?? requestyDefaultModelId - const info = routerModels.requesty[id] - return info - ? { id, info } - : { id: requestyDefaultModelId, info: routerModels.requesty[requestyDefaultModelId] } + return { id, info: providerModelRecord?.[id] || providerModelRecord?.[requestyDefaultModelId] } } case "glama": { const id = apiConfiguration.glamaModelId ?? glamaDefaultModelId - const info = routerModels.glama[id] - return info ? { id, info } : { id: glamaDefaultModelId, info: routerModels.glama[glamaDefaultModelId] } + return { id, info: providerModelRecord?.[id] || providerModelRecord?.[glamaDefaultModelId] } } case "unbound": { const id = apiConfiguration.unboundModelId ?? unboundDefaultModelId - const info = routerModels.unbound[id] - return info - ? { id, info } - : { id: unboundDefaultModelId, info: routerModels.unbound[unboundDefaultModelId] } + return { id, info: providerModelRecord?.[id] || providerModelRecord?.[unboundDefaultModelId] } } case "litellm": { const id = apiConfiguration.litellmModelId ?? litellmDefaultModelId - const info = routerModels.litellm[id] - return info - ? { id, info } - : { id: litellmDefaultModelId, info: routerModels.litellm[litellmDefaultModelId] } + return { id, info: providerModelRecord?.[id] || providerModelRecord?.[litellmDefaultModelId] } + } + case "ollama": { + const id = apiConfiguration.ollamaModelId ?? "" + return { id, info: providerModelRecord?.[id] || openAiModelInfoSaneDefaults } + } + case "lmstudio": { + const id = apiConfiguration.lmStudioModelId ?? "" + return { id, info: providerModelRecord?.[id] || openAiModelInfoSaneDefaults } + } + case "vscode-lm": { + const selector = apiConfiguration.vsCodeLmModelSelector + let selectedModelId: string + + if (selector && selector.id) { + selectedModelId = selector.id + } else if (selector && selector.vendor && selector.family) { + selectedModelId = `${selector.vendor}/${selector.family}`.toLowerCase() + } else { + selectedModelId = vscodeLlmDefaultModelId + } + + let modelInfo = providerModelRecord?.[selectedModelId] + + if (!modelInfo) { + modelInfo = providerModelRecord?.[vscodeLlmDefaultModelId] + } + + if (!modelInfo) { + modelInfo = vscodeLlmModels[vscodeLlmDefaultModelId as VscodeLlmModelId] + } + + return { + id: selectedModelId, + info: { ...openAiModelInfoSaneDefaults, ...modelInfo, supportsImages: false }, + } } case "xai": { const id = apiConfiguration.apiModelId ?? xaiDefaultModelId const info = xaiModels[id as keyof typeof xaiModels] - return info ? { id, info } : { id: xaiDefaultModelId, info: xaiModels[xaiDefaultModelId] } + return { id, info: info || xaiModels[xaiDefaultModelId] } } case "groq": { const id = apiConfiguration.apiModelId ?? groqDefaultModelId const info = groqModels[id as keyof typeof groqModels] - return info ? { id, info } : { id: groqDefaultModelId, info: groqModels[groqDefaultModelId] } + return { id, info: info || groqModels[groqDefaultModelId] } } case "chutes": { const id = apiConfiguration.apiModelId ?? chutesDefaultModelId const info = chutesModels[id as keyof typeof chutesModels] - return info ? { id, info } : { id: chutesDefaultModelId, info: chutesModels[chutesDefaultModelId] } + return { id, info: info || chutesModels[chutesDefaultModelId] } } case "bedrock": { const id = apiConfiguration.apiModelId ?? bedrockDefaultModelId - const info = bedrockModels[id as keyof typeof bedrockModels] - - // Special case for custom ARN. if (id === "custom-arn") { return { id, info: { maxTokens: 5000, contextWindow: 128_000, supportsPromptCache: false, supportsImages: true }, } } - - return info ? { id, info } : { id: bedrockDefaultModelId, info: bedrockModels[bedrockDefaultModelId] } + const info = bedrockModels[id as keyof typeof bedrockModels] + return { id, info: info || bedrockModels[bedrockDefaultModelId] } } case "vertex": { const id = apiConfiguration.apiModelId ?? vertexDefaultModelId const info = vertexModels[id as keyof typeof vertexModels] - return info ? { id, info } : { id: vertexDefaultModelId, info: vertexModels[vertexDefaultModelId] } + return { id, info: info || vertexModels[vertexDefaultModelId] } } case "gemini": { const id = apiConfiguration.apiModelId ?? geminiDefaultModelId const info = geminiModels[id as keyof typeof geminiModels] - return info ? { id, info } : { id: geminiDefaultModelId, info: geminiModels[geminiDefaultModelId] } + return { id, info: info || geminiModels[geminiDefaultModelId] } } case "deepseek": { const id = apiConfiguration.apiModelId ?? deepSeekDefaultModelId const info = deepSeekModels[id as keyof typeof deepSeekModels] - return info ? { id, info } : { id: deepSeekDefaultModelId, info: deepSeekModels[deepSeekDefaultModelId] } + return { id, info: info || deepSeekModels[deepSeekDefaultModelId] } } case "openai-native": { const id = apiConfiguration.apiModelId ?? openAiNativeDefaultModelId const info = openAiNativeModels[id as keyof typeof openAiNativeModels] - return info - ? { id, info } - : { id: openAiNativeDefaultModelId, info: openAiNativeModels[openAiNativeDefaultModelId] } + return { id, info: info || openAiNativeModels[openAiNativeDefaultModelId] } } case "mistral": { const id = apiConfiguration.apiModelId ?? mistralDefaultModelId const info = mistralModels[id as keyof typeof mistralModels] - return info ? { id, info } : { id: mistralDefaultModelId, info: mistralModels[mistralDefaultModelId] } + return { id, info: info || mistralModels[mistralDefaultModelId] } } case "openai": { const id = apiConfiguration.openAiModelId ?? "" - const info = apiConfiguration?.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults - return { id, info } + return { id, info: apiConfiguration?.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults } } - case "ollama": { - const id = apiConfiguration.ollamaModelId ?? "" - const info = openAiModelInfoSaneDefaults - return { id, info } - } - case "lmstudio": { - const id = apiConfiguration.lmStudioModelId ?? "" - const info = openAiModelInfoSaneDefaults - return { id, info } - } - case "vscode-lm": { - const id = apiConfiguration?.vsCodeLmModelSelector - ? `${apiConfiguration.vsCodeLmModelSelector.vendor}/${apiConfiguration.vsCodeLmModelSelector.family}` - : vscodeLlmDefaultModelId - const modelFamily = apiConfiguration?.vsCodeLmModelSelector?.family ?? vscodeLlmDefaultModelId - const info = vscodeLlmModels[modelFamily as keyof typeof vscodeLlmModels] - return { id, info: { ...openAiModelInfoSaneDefaults, ...info, supportsImages: false } } // VSCode LM API currently doesn't support images. - } - // case "anthropic": - // case "human-relay": - // case "fake-ai": default: { const id = apiConfiguration.apiModelId ?? anthropicDefaultModelId const info = anthropicModels[id as keyof typeof anthropicModels] - return info ? { id, info } : { id: anthropicDefaultModelId, info: anthropicModels[anthropicDefaultModelId] } + return { id, info: info || anthropicModels[anthropicDefaultModelId] } } } } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 0709ec0ad6..7952ee7721 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -19,7 +19,6 @@ import { Mode, defaultModeSlug, defaultPrompts } from "@roo/modes" import { CustomSupportPrompts } from "@roo/support-prompt" import { experimentDefault } from "@roo/experiments" import { TelemetrySetting } from "@roo/TelemetrySetting" -import { RouterModels } from "@roo/api" import { vscode } from "@src/utils/vscode" import { convertTextMateToHljs } from "@src/utils/textMateToHljs" @@ -115,7 +114,6 @@ export interface ExtensionStateContextType extends ExtensionState { setAutoCondenseContext: (value: boolean) => void autoCondenseContextPercent: number setAutoCondenseContextPercent: (value: number) => void - routerModels?: RouterModels } export const ExtensionStateContext = createContext(undefined) @@ -217,7 +215,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [openedTabs, setOpenedTabs] = useState>([]) const [mcpServers, setMcpServers] = useState([]) const [currentCheckpoint, setCurrentCheckpoint] = useState() - const [extensionRouterModels, setExtensionRouterModels] = useState(undefined) const setListApiConfigMeta = useCallback( (value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), @@ -285,10 +282,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setListApiConfigMeta(message.listApiConfig ?? []) break } - case "routerModels": { - setExtensionRouterModels(message.routerModels) - break - } } }, [setListApiConfigMeta], @@ -314,7 +307,6 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode fuzzyMatchThreshold: state.fuzzyMatchThreshold, writeDelayMs: state.writeDelayMs, screenshotQuality: state.screenshotQuality, - routerModels: extensionRouterModels, setExperimentEnabled: (id, enabled) => setState((prevState) => ({ ...prevState, experiments: { ...prevState.experiments, [id]: enabled } })), setApiConfiguration, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 7986c27883..db34fb9a54 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Clau API de Requesty", "refreshModels": { "label": "Actualitzar models", + "noModelsFound": "No s'han trobat models. Si us plau, torneu-ho a provar.", "hint": "Si us plau, torneu a obrir la configuració per veure els models més recents.", "loading": "Actualitzant la llista de models...", "success": "Llista de models actualitzada correctament!", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 3bb81837f6..e44ebd9c1a 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty API-Schlüssel", "refreshModels": { "label": "Modelle aktualisieren", + "noModelsFound": "Keine Modelle gefunden. Bitte versuche es erneut.", "hint": "Bitte öffne die Einstellungen erneut, um die neuesten Modelle zu sehen.", "loading": "Modellliste wird aktualisiert...", "success": "Modellliste erfolgreich aktualisiert!", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 752b034228..9eaf0f1ced 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty API Key", "refreshModels": { "label": "Refresh Models", + "noModelsFound": "No models found. Please try again.", "hint": "Please reopen the settings to see the latest models.", "loading": "Refreshing models list...", "success": "Models list refreshed successfully!", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 983d2df266..c9881d90ee 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Clave API de Requesty", "refreshModels": { "label": "Actualizar modelos", + "noModelsFound": "No se encontraron modelos. Por favor, inténtalo de nuevo.", "hint": "Por favor, vuelve a abrir la configuración para ver los modelos más recientes.", "loading": "Actualizando lista de modelos...", "success": "¡Lista de modelos actualizada correctamente!", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 24f1aa4e9d..bf312f9f65 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Clé API Requesty", "refreshModels": { "label": "Actualiser les modèles", + "noModelsFound": "Aucun modèle trouvé. Veuillez réessayer.", "hint": "Veuillez rouvrir les paramètres pour voir les modèles les plus récents.", "loading": "Actualisation de la liste des modèles...", "success": "Liste des modèles actualisée avec succès !", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 04476ece10..232ecf9ea2 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty API कुंजी", "refreshModels": { "label": "मॉडल रिफ्रेश करें", + "noModelsFound": "कोई मॉडल नहीं मिला। कृपया फिर से कोशिश करें।", "hint": "नवीनतम मॉडल देखने के लिए कृपया सेटिंग्स को फिर से खोलें।", "loading": "मॉडल सूची अपडेट हो रही है...", "success": "मॉडल सूची सफलतापूर्वक अपडेट की गई!", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index cbfc9cdfac..2ff56dbad7 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Chiave API Requesty", "refreshModels": { "label": "Aggiorna modelli", + "noModelsFound": "Nessun modello trovato. Riprova.", "hint": "Riapri le impostazioni per vedere i modelli più recenti.", "loading": "Aggiornamento dell'elenco dei modelli...", "success": "Elenco dei modelli aggiornato con successo!", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index c8ac18fd01..9692e9aa8b 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty APIキー", "refreshModels": { "label": "モデルを更新", + "noModelsFound": "モデルが見つかりません。もう一度お試しください。", "hint": "最新のモデルを表示するには設定を再度開いてください。", "loading": "モデルリストを更新中...", "success": "モデルリストが正常に更新されました!", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index ac8069a184..3ecf6b8eba 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty API 키", "refreshModels": { "label": "모델 새로고침", + "noModelsFound": "모델을 찾을 수 없습니다. 다시 시도해주세요.", "hint": "최신 모델을 보려면 설정을 다시 열어주세요.", "loading": "모델 목록 새로고침 중...", "success": "모델 목록이 성공적으로 새로고침되었습니다!", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index c4ee8e58e3..1a911031cf 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty API-sleutel", "refreshModels": { "label": "Modellen verversen", + "noModelsFound": "Geen modellen gevonden. Probeer het opnieuw.", "hint": "Open de instellingen opnieuw om de nieuwste modellen te zien.", "loading": "Modellenlijst wordt vernieuwd...", "success": "Modellenlijst succesvol vernieuwd!", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index c499976cc3..77c759e98d 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Klucz API Requesty", "refreshModels": { "label": "Odśwież modele", + "noModelsFound": "Nie znaleziono modeli. Spróbuj ponownie.", "hint": "Proszę ponownie otworzyć ustawienia, aby zobaczyć najnowsze modele.", "loading": "Odświeżanie listy modeli...", "success": "Lista modeli została pomyślnie odświeżona!", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 893e52d402..c021973a89 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Chave de API Requesty", "refreshModels": { "label": "Atualizar modelos", + "noModelsFound": "Nenhum modelo encontrado. Tente novamente.", "hint": "Por favor, reabra as configurações para ver os modelos mais recentes.", "loading": "Atualizando lista de modelos...", "success": "Lista de modelos atualizada com sucesso!", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 5c611d8a45..dd5f1a3a15 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty API-ключ", "refreshModels": { "label": "Обновить модели", + "noModelsFound": "Модели не найдены. Попробуйте еще раз.", "hint": "Пожалуйста, откройте настройки заново, чтобы увидеть последние модели.", "loading": "Обновление списка моделей...", "success": "Список моделей успешно обновлен!", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 25ec36780f..7c075975ba 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty API Anahtarı", "refreshModels": { "label": "Modelleri Yenile", + "noModelsFound": "Model bulunamadı. Lütfen tekrar deneyin.", "hint": "En son modelleri görmek için lütfen ayarları yeniden açın.", "loading": "Model listesi yenileniyor...", "success": "Model listesi başarıyla yenilendi!", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 2871f73f3e..b190745e87 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Khóa API Requesty", "refreshModels": { "label": "Làm mới mô hình", + "noModelsFound": "Không tìm thấy mô hình nào. Vui lòng thử lại.", "hint": "Vui lòng mở lại cài đặt để xem các mô hình mới nhất.", "loading": "Đang làm mới danh sách mô hình...", "success": "Danh sách mô hình đã được làm mới thành công!", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 51f247fd0d..b72fab0fb1 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty API 密钥", "refreshModels": { "label": "刷新模型", + "noModelsFound": "未找到模型。请重试。", "hint": "请重新打开设置以查看最新模型。", "loading": "正在刷新模型列表...", "success": "模型列表刷新成功!", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 595194f97c..02715046b7 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -152,6 +152,7 @@ "requestyApiKey": "Requesty API 金鑰", "refreshModels": { "label": "重新整理模型", + "noModelsFound": "找不到模型。請重試。", "hint": "請重新開啟設定以查看最新模型。", "loading": "正在重新整理模型列表...", "success": "模型列表重新整理成功!",