diff --git a/.changeset/curly-plants-pull.md b/.changeset/curly-plants-pull.md new file mode 100644 index 0000000000..0f425cb859 --- /dev/null +++ b/.changeset/curly-plants-pull.md @@ -0,0 +1,11 @@ +--- +"roo-cline": patch +--- + +New models for the Chutes provider: + +- Qwen/Qwen3-235B-A22B +- Qwen/Qwen3-32B +- Qwen/Qwen3-30B-A3B +- Qwen/Qwen3-14B +- Qwen/Qwen3-8B diff --git a/.changeset/seven-kids-return.md b/.changeset/seven-kids-return.md new file mode 100644 index 0000000000..d4da5cbc03 --- /dev/null +++ b/.changeset/seven-kids-return.md @@ -0,0 +1,10 @@ +--- +"roo-cline": minor +--- + +Adds refresh models button for Unbound provider +Adds a button above model picker to refresh models based on the current API Key. + +1. Clicking the refresh button saves the API Key and calls /models endpoint using that. +2. Gets the new models and updates the current model if it is invalid for the given API Key. +3. The refresh button also flushes existing Unbound models and refetches them. diff --git a/.gitignore b/.gitignore index 3a541f1b09..b2ddc6d9f0 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,7 @@ logs # Vite development .vite-port + +# IntelliJ and Qodo plugin folders +.idea/ +.qodo/ diff --git a/.vscodeignore b/.vscodeignore index ce2e88a388..50f21d23c7 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -68,3 +68,7 @@ assets/docs/** # Include .env file for telemetry !.env + +# Ignore IntelliJ and Qodo plugin folders +.idea/** +.qodo/** \ No newline at end of file diff --git a/esbuild.js b/esbuild.js index f38de8c15f..2b684ea248 100644 --- a/esbuild.js +++ b/esbuild.js @@ -34,10 +34,17 @@ const copyWasmFiles = { // tiktoken WASM file fs.copyFileSync( - path.join(nodeModulesDir, "tiktoken", "tiktoken_bg.wasm"), + path.join(nodeModulesDir, "tiktoken", "lite", "tiktoken_bg.wasm"), path.join(distDir, "tiktoken_bg.wasm"), ) + // Also copy to the workers directory + fs.mkdirSync(path.join(distDir, "workers"), { recursive: true }) + fs.copyFileSync( + path.join(nodeModulesDir, "tiktoken", "lite", "tiktoken_bg.wasm"), + path.join(distDir, "workers", "tiktoken_bg.wasm"), + ) + // Main tree-sitter WASM file fs.copyFileSync( path.join(nodeModulesDir, "web-tree-sitter", "tree-sitter.wasm"), diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 4cb991e7d1..971e026fe0 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -6,6 +6,7 @@ import NodeCache from "node-cache" import { ContextProxy } from "../../../core/config/ContextProxy" import { getCacheDirectoryPath } from "../../../shared/storagePathManager" import { RouterName, ModelRecord } from "../../../shared/api" +import { fileExistsAtPath } from "../../../utils/fs" import { getOpenRouterModels } from "./openrouter" import { getRequestyModels } from "./requesty" @@ -21,6 +22,14 @@ async function writeModels(router: RouterName, data: ModelRecord) { await fs.writeFile(path.join(cacheDir, filename), JSON.stringify(data)) } +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 +} + /** * Get models from the cache or fetch them from the provider and cache them. * There are two caches: @@ -37,29 +46,26 @@ export const getModels = async ( apiKey: string | undefined = undefined, baseUrl: string | undefined = undefined, ): Promise => { - // If this call is meant for a refresh (indicated by apiKey/baseUrl for specific routers), - // the memory cache should have been flushed by the caller (e.g., webviewMessageHandler). - // Otherwise, for general calls, check memory cache first. - const modelsFromMemory = memoryCache.get(router) - if (modelsFromMemory) { - return modelsFromMemory + let models = memoryCache.get(router) + if (models) { + return models } - let fetchedModels: ModelRecord try { switch (router) { case "openrouter": - fetchedModels = await getOpenRouterModels() + models = await getOpenRouterModels() break case "requesty": - // Assuming getRequestyModels will throw if apiKey is needed and not provided or invalid. - fetchedModels = await getRequestyModels(apiKey) + // Requesty models endpoint requires an API key for per-user custom policies + models = await getRequestyModels(apiKey) break case "glama": - fetchedModels = await getGlamaModels() + models = await getGlamaModels() break case "unbound": - fetchedModels = await getUnboundModels() + // Unbound models endpoint requires an API key to fetch application specific models + models = await getUnboundModels(apiKey) break case "litellm": if (!baseUrl || !apiKey) { @@ -67,7 +73,7 @@ export const getModels = async ( // However, for robustness, if called without baseUrl for litellm, it would fail in getLiteLLMModels or here. throw new Error("Base URL and api key are required for LiteLLM models.") } - fetchedModels = await getLiteLLMModels(apiKey || "", baseUrl) + models = await getLiteLLMModels(apiKey || "", baseUrl) break default: // Ensures router is exhaustively checked if RouterName is a strict union @@ -76,11 +82,18 @@ export const getModels = async ( } // Cache the fetched models (even if empty, to signify a successful fetch with no models) - memoryCache.set(router, fetchedModels) - await writeModels(router, fetchedModels).catch((err) => + memoryCache.set(router, models) + await writeModels(router, models).catch((err) => console.error(`[getModels] Error writing ${router} models to file cache:`, err), ) - return fetchedModels + + try { + models = await readModels(router) + // console.log(`[getModels] read ${router} models from file cache`) + } catch (error) { + console.error(`[getModels] error reading ${router} models from file cache`, error) + } + return models || {} } 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 for ${router}:`, error) @@ -89,10 +102,6 @@ export const getModels = async ( } } -/** - * Flush models memory cache for a specific router - * @param router - The router to flush models for. - */ export const flushModels = async (router: RouterName) => { memoryCache.del(router) } diff --git a/src/api/providers/fetchers/unbound.ts b/src/api/providers/fetchers/unbound.ts index 73a8c2f897..7834debf35 100644 --- a/src/api/providers/fetchers/unbound.ts +++ b/src/api/providers/fetchers/unbound.ts @@ -2,11 +2,17 @@ import axios from "axios" import { ModelInfo } from "../../../shared/api" -export async function getUnboundModels(): Promise> { +export async function getUnboundModels(apiKey?: string | null): Promise> { const models: Record = {} try { - const response = await axios.get("https://api.getunbound.ai/models") + const headers: Record = {} + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const response = await axios.get("https://api.getunbound.ai/models", { headers }) if (response.data) { const rawModels: Record = response.data @@ -40,6 +46,7 @@ export async function getUnboundModels(): Promise> { } } catch (error) { console.error(`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + throw new Error(`Failed to fetch Unbound models: ${error instanceof Error ? error.message : "Unknown error"}`) } return models diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 918041e459..9a23272d28 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -350,7 +350,7 @@ export class Task extends EventEmitter { await this.providerRef.deref()?.updateTaskHistory(historyItem) } catch (error) { - console.error("Failed to save cline messages:", error) + console.error("Failed to save Roo messages:", error) } } @@ -372,7 +372,7 @@ export class Task extends EventEmitter { // simply removes the reference to this instance, but the instance is // still alive until this promise resolves or rejects.) if (this.abort) { - throw new Error(`[Cline#ask] task ${this.taskId}.${this.instanceId} aborted`) + throw new Error(`[RooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } let askTs: number @@ -492,7 +492,7 @@ export class Task extends EventEmitter { } = {}, ): Promise { if (this.abort) { - throw new Error(`[Cline#say] task ${this.taskId}.${this.instanceId} aborted`) + throw new Error(`[RooCode#say] task ${this.taskId}.${this.instanceId} aborted`) } if (partial !== undefined) { @@ -623,7 +623,7 @@ export class Task extends EventEmitter { } catch (error) { this.providerRef .deref() - ?.log(`Error failed to add reply from subtast into conversation of parent task, error: ${error}`) + ?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`) throw error } @@ -957,7 +957,7 @@ export class Task extends EventEmitter { includeFileDetails: boolean = false, ): Promise { if (this.abort) { - throw new Error(`[Cline#recursivelyMakeClineRequests] task ${this.taskId}.${this.instanceId} aborted`) + throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`) } if (this.consecutiveMistakeCount >= this.consecutiveMistakeLimit) { @@ -1253,7 +1253,7 @@ export class Task extends EventEmitter { // Need to call here in case the stream was aborted. if (this.abort || this.abandoned) { - throw new Error(`[Cline#recursivelyMakeClineRequests] task ${this.taskId}.${this.instanceId} aborted`) + throw new Error(`[RooCode#recursivelyMakeRooRequests] task ${this.taskId}.${this.instanceId} aborted`) } this.didCompleteReadingStream = true diff --git a/src/shared/api.ts b/src/shared/api.ts index 25d163c657..dd8bd5bef4 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1533,6 +1533,11 @@ export type ChutesModelId = | "deepseek-ai/DeepSeek-V3-Base" | "deepseek-ai/DeepSeek-R1-Zero" | "deepseek-ai/DeepSeek-V3-0324" + | "Qwen/Qwen3-235B-A22B" + | "Qwen/Qwen3-32B" + | "Qwen/Qwen3-30B-A3B" + | "Qwen/Qwen3-14B" + | "Qwen/Qwen3-8B" | "microsoft/MAI-DS-R1-FP8" | "tngtech/DeepSeek-R1T-Chimera" export const chutesDefaultModelId: ChutesModelId = "deepseek-ai/DeepSeek-R1" @@ -1663,6 +1668,51 @@ export const chutesModels = { outputPrice: 0, description: "DeepSeek V3 (0324) model.", }, + "Qwen/Qwen3-235B-A22B": { + maxTokens: 32768, + contextWindow: 40960, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + description: "Qwen3 235B A22B model.", + }, + "Qwen/Qwen3-32B": { + maxTokens: 32768, + contextWindow: 40960, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + description: "Qwen3 32B model.", + }, + "Qwen/Qwen3-30B-A3B": { + maxTokens: 32768, + contextWindow: 40960, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + description: "Qwen3 30B A3B model.", + }, + "Qwen/Qwen3-14B": { + maxTokens: 32768, + contextWindow: 40960, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + description: "Qwen3 14B model.", + }, + "Qwen/Qwen3-8B": { + maxTokens: 32768, + contextWindow: 40960, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + description: "Qwen3 8B model.", + }, "microsoft/MAI-DS-R1-FP8": { maxTokens: 32768, contextWindow: 163840, diff --git a/tsconfig.json b/tsconfig.json index 1d70336fc1..0cddfc71dc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,5 +21,5 @@ "useUnknownInCatchVariables": false }, "include": ["src/**/*", "scripts/**/*", ".changeset/**/*"], - "exclude": ["node_modules", ".vscode-test", "webview-ui"] + "exclude": ["node_modules", ".vscode-test", "webview-ui", ".idea", ".qodo"] } diff --git a/webview-ui/src/components/settings/providers/Unbound.tsx b/webview-ui/src/components/settings/providers/Unbound.tsx index 77b24bb7cc..3d5aa0c67a 100644 --- a/webview-ui/src/components/settings/providers/Unbound.tsx +++ b/webview-ui/src/components/settings/providers/Unbound.tsx @@ -1,10 +1,13 @@ -import { useCallback } from "react" +import { useCallback, useState, useRef } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { useQueryClient } from "@tanstack/react-query" import { ProviderSettings, RouterModels, unboundDefaultModelId } from "@roo/shared/api" import { useAppTranslation } from "@src/i18n/TranslationContext" import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink" +import { vscode } from "@src/utils/vscode" +import { Button } from "@src/components/ui" import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" @@ -17,6 +20,13 @@ type UnboundProps = { export const Unbound = ({ apiConfiguration, setApiConfigurationField, routerModels }: 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 invalidKeyTimerRef = useRef() const handleInputChange = useCallback( ( @@ -29,6 +39,90 @@ export const Unbound = ({ apiConfiguration, setApiConfigurationField, routerMode [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 + } else { + return true + } + }, [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) + } + }, [saveConfiguration, requestModels]) + return ( <> )} +
+ +
+ {didRefetch && ( +
+ {t("settings:providers.unboundRefreshModelsSuccess")} +
+ )} + {isInvalidKey && ( +
+ {t("settings:providers.unboundInvalidApiKey")} +
+ )}