diff --git a/src/api/providers/__tests__/litellm.test.ts b/src/api/providers/__tests__/litellm.test.ts new file mode 100644 index 0000000000..6cbd4ceede --- /dev/null +++ b/src/api/providers/__tests__/litellm.test.ts @@ -0,0 +1,249 @@ +// npx jest src/api/providers/__tests__/litellm.test.ts + +import { Anthropic } from "@anthropic-ai/sdk" // For message types +import OpenAI from "openai" + +import { LiteLLMHandler } from "../litellm" +import { ApiHandlerOptions, litellmDefaultModelId, litellmDefaultModelInfo, ModelInfo } from "../../../shared/api" +import * as modelCache from "../fetchers/modelCache" + +const mockOpenAICreateCompletions = jest.fn() +jest.mock("openai", () => { + return jest.fn(() => ({ + chat: { + completions: { + create: mockOpenAICreateCompletions, + }, + }, + })) +}) + +jest.mock("../fetchers/modelCache", () => ({ + getModels: jest.fn(), +})) + +const mockGetModels = modelCache.getModels as jest.Mock + +describe("LiteLLMHandler", () => { + const defaultMockOptions: ApiHandlerOptions = { + litellmApiKey: "test-litellm-key", + litellmModelId: "litellm-test-model", + litellmBaseUrl: "http://mock-litellm-server:8000", + modelTemperature: 0.1, // Add a default temperature for tests + } + + const mockModelInfo: ModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: true, + supportsComputerUse: false, + description: "A test LiteLLM model", + } + + beforeEach(() => { + jest.clearAllMocks() + + mockGetModels.mockResolvedValue({ + [defaultMockOptions.litellmModelId!]: mockModelInfo, + }) + // Spy on supportsTemperature and default to true for most tests, can be overridden + jest.spyOn(LiteLLMHandler.prototype as any, "supportsTemperature").mockReturnValue(true) + }) + + describe("constructor", () => { + it("initializes with correct options and defaults", () => { + const handler = new LiteLLMHandler(defaultMockOptions) // This will call new OpenAI() + expect(handler).toBeInstanceOf(LiteLLMHandler) + // Check if the mock constructor was called with the right params + expect(OpenAI).toHaveBeenCalledWith({ + baseURL: defaultMockOptions.litellmBaseUrl, + apiKey: defaultMockOptions.litellmApiKey, + }) + }) + + it("uses default baseURL if not provided", () => { + new LiteLLMHandler({ litellmApiKey: "key", litellmModelId: "id" }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "http://localhost:4000" })) + }) + + it("uses dummy API key if not provided", () => { + new LiteLLMHandler({ litellmBaseUrl: "url", litellmModelId: "id" }) + expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-1234" })) + }) + }) + + describe("fetchModel", () => { + it("returns correct model info when modelId is provided and found in getModels", async () => { + const handler = new LiteLLMHandler(defaultMockOptions) + const result = await handler.fetchModel() + expect(mockGetModels).toHaveBeenCalledWith({ + provider: "litellm", + apiKey: defaultMockOptions.litellmApiKey, + baseUrl: defaultMockOptions.litellmBaseUrl, + }) + expect(result).toEqual({ id: defaultMockOptions.litellmModelId, info: mockModelInfo }) + }) + + it("returns defaultModelInfo if provided modelId is NOT found in getModels result", async () => { + mockGetModels.mockResolvedValueOnce({ "another-model": { contextWindow: 1, supportsPromptCache: false } }) + const handler = new LiteLLMHandler(defaultMockOptions) + const result = await handler.fetchModel() + expect(result.id).toBe(litellmDefaultModelId) + expect(result.info).toEqual(litellmDefaultModelInfo) + }) + + it("uses defaultModelId and its info if litellmModelId option is undefined and defaultModelId is in getModels", async () => { + const specificDefaultModelInfo = { ...mockModelInfo, description: "Specific Default Model Info" } + mockGetModels.mockResolvedValueOnce({ [litellmDefaultModelId]: specificDefaultModelInfo }) + const handler = new LiteLLMHandler({ ...defaultMockOptions, litellmModelId: undefined }) + const result = await handler.fetchModel() + expect(result.id).toBe(litellmDefaultModelId) + expect(result.info).toEqual(specificDefaultModelInfo) + }) + + it("uses defaultModelId and defaultModelInfo if litellmModelId option is undefined and defaultModelId is NOT in getModels", async () => { + mockGetModels.mockResolvedValueOnce({ "some-other-model": mockModelInfo }) + const handler = new LiteLLMHandler({ ...defaultMockOptions, litellmModelId: undefined }) + const result = await handler.fetchModel() + expect(result.id).toBe(litellmDefaultModelId) + expect(result.info).toEqual(litellmDefaultModelInfo) + }) + + it("throws an error if getModels fails", async () => { + mockGetModels.mockRejectedValueOnce(new Error("Network error")) + const handler = new LiteLLMHandler(defaultMockOptions) + await expect(handler.fetchModel()).rejects.toThrow("Network error") + }) + }) + + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + // mockCreateGlobal is no longer needed here, use mockOpenAICreateCompletions directly + + beforeEach(() => { + // mockOpenAICreateCompletions is already cleared by jest.clearAllMocks() in the outer beforeEach + // or mockOpenAICreateCompletions.mockClear() if we want to be very specific + }) + + it("streams text and usage chunks correctly", async () => { + const mockStreamData = { + async *[Symbol.asyncIterator]() { + yield { id: "chunk1", choices: [{ delta: { content: "Response part 1" } }], usage: null } + yield { id: "chunk2", choices: [{ delta: { content: " part 2" } }], usage: null } + yield { id: "chunk3", choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 5 } } + }, + } + mockOpenAICreateCompletions.mockReturnValue({ + withResponse: jest.fn().mockResolvedValue({ data: mockStreamData }), + }) + + const handler = new LiteLLMHandler(defaultMockOptions) + const generator = handler.createMessage(systemPrompt, messages) + const chunks = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + expect(chunks).toEqual([ + { type: "text", text: "Response part 1" }, + { type: "text", text: " part 2" }, + { type: "usage", inputTokens: 10, outputTokens: 5 }, + ]) + expect(mockOpenAICreateCompletions).toHaveBeenCalledWith({ + model: defaultMockOptions.litellmModelId, + max_tokens: mockModelInfo.maxTokens, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: "Hello" }, + ], + stream: true, + stream_options: { include_usage: true }, + temperature: defaultMockOptions.modelTemperature, + }) + }) + + it("handles temperature option if supported", async () => { + const handler = new LiteLLMHandler({ ...defaultMockOptions, modelTemperature: 0.7 }) + const mockStreamData = { async *[Symbol.asyncIterator]() {} } + mockOpenAICreateCompletions.mockReturnValue({ + withResponse: jest.fn().mockResolvedValue({ data: mockStreamData }), + }) + + const generator = handler.createMessage(systemPrompt, messages) + for await (const _ of generator) { + } + + expect(mockOpenAICreateCompletions).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0.7 })) + }) + + it("does not include temperature if not supported by model", async () => { + ;(LiteLLMHandler.prototype as any).supportsTemperature.mockReturnValue(false) + const handler = new LiteLLMHandler(defaultMockOptions) + const mockStreamData = { async *[Symbol.asyncIterator]() {} } + mockOpenAICreateCompletions.mockReturnValue({ + withResponse: jest.fn().mockResolvedValue({ data: mockStreamData }), + }) + + const generator = handler.createMessage(systemPrompt, messages) + for await (const _ of generator) { + } + + const callArgs = mockOpenAICreateCompletions.mock.calls[0][0] + expect(callArgs.temperature).toBeUndefined() + }) + + it("throws a formatted error if API call (streaming) fails", async () => { + const apiError = new Error("LLM Provider Error") + // Simulate the error occurring within the stream itself + mockOpenAICreateCompletions.mockReturnValue({ + withResponse: jest.fn().mockResolvedValue({ + data: { + async *[Symbol.asyncIterator]() { + throw apiError + }, + }, + }), + }) + + const handler = new LiteLLMHandler(defaultMockOptions) + const generator = handler.createMessage(systemPrompt, messages) + await expect(async () => { + for await (const _ of generator) { + } + }).rejects.toThrow("LiteLLM streaming error: " + apiError.message) + }) + }) + + describe("completePrompt", () => { + const prompt = "Translate 'hello' to French." + // mockCreateGlobal is no longer needed here, use mockOpenAICreateCompletions directly + + beforeEach(() => { + // mockOpenAICreateCompletions is already cleared by jest.clearAllMocks() in the outer beforeEach + }) + + it("returns completion successfully", async () => { + mockOpenAICreateCompletions.mockResolvedValueOnce({ choices: [{ message: { content: "Bonjour" } }] }) + const handler = new LiteLLMHandler(defaultMockOptions) + const result = await handler.completePrompt(prompt) + + expect(result).toBe("Bonjour") + expect(mockOpenAICreateCompletions).toHaveBeenCalledWith({ + model: defaultMockOptions.litellmModelId, + max_tokens: mockModelInfo.maxTokens, + messages: [{ role: "user", content: prompt }], + temperature: defaultMockOptions.modelTemperature, + }) + }) + + it("throws a formatted error if API call fails", async () => { + mockOpenAICreateCompletions.mockRejectedValueOnce(new Error("Completion API Down")) + const handler = new LiteLLMHandler(defaultMockOptions) + await expect(handler.completePrompt(prompt)).rejects.toThrow( + "LiteLLM completion error: Completion API Down", + ) + }) + }) +}) diff --git a/src/api/providers/fetchers/litellm.ts b/src/api/providers/fetchers/litellm.ts index ac143b8acb..a97d94fb7e 100644 --- a/src/api/providers/fetchers/litellm.ts +++ b/src/api/providers/fetchers/litellm.ts @@ -7,6 +7,7 @@ import { COMPUTER_USE_MODELS, ModelRecord } from "../../../shared/api" * @param apiKey The API key for the LiteLLM server * @param baseUrl The base URL of the LiteLLM server * @returns A promise that resolves to a record of model IDs to model info + * @throws Will throw an error if the request fails or the response is not as expected. */ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise { try { @@ -18,7 +19,8 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise headers["Authorization"] = `Bearer ${apiKey}` } - const response = await axios.get(`${baseUrl}/v1/model/info`, { headers }) + // Added timeout to prevent indefinite hanging + const response = await axios.get(`${baseUrl}/v1/model/info`, { headers, timeout: 15000 }) const models: ModelRecord = {} const computerModels = Array.from(COMPUTER_USE_MODELS) @@ -32,11 +34,17 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise if (!modelName || !modelInfo || !litellmModelName) continue + let determinedMaxTokens = modelInfo.max_tokens || modelInfo.max_output_tokens || 8192 + + if (modelName.includes("claude-3-7-sonnet")) { + // due to https://github.com/BerriAI/litellm/issues/8984 until proper extended thinking support is added + determinedMaxTokens = 64000 + } + models[modelName] = { - maxTokens: modelInfo.max_tokens || 8192, + maxTokens: determinedMaxTokens, contextWindow: modelInfo.max_input_tokens || 200000, supportsImages: Boolean(modelInfo.supports_vision), - // litellm_params.model may have a prefix like openrouter/ supportsComputerUse: computerModels.some((computer_model) => litellmModelName.endsWith(computer_model), ), @@ -48,11 +56,25 @@ export async function getLiteLLMModels(apiKey: string, baseUrl: string): Promise description: `${modelName} via LiteLLM proxy`, } } + } else { + // If response.data.data is not in the expected format, consider it an error. + console.error("Error fetching LiteLLM models: Unexpected response format", response.data) + throw new Error("Failed to fetch LiteLLM models: Unexpected response format.") } return models - } catch (error) { - console.error("Error fetching LiteLLM models:", error) - return {} + } catch (error: any) { + console.error("Error fetching LiteLLM models:", error.message ? error.message : error) + 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.`, + ) + } else if (axios.isAxiosError(error) && error.request) { + throw new Error( + "Failed to fetch LiteLLM models: No response from server. Check LiteLLM server status and base URL.", + ) + } else { + throw new Error(`Failed to fetch LiteLLM models: ${error.message || "An unknown error occurred."}`) + } } } diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 9517bda8ae..3879a9f625 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" @@ -36,75 +36,63 @@ async function readModels(router: RouterName): Promise * 1. Memory cache - This is a simple in-memory cache that is used to store models for a short period of time. * 2. File cache - This is a file-based cache that is used to store models for a longer period of time. * - * @param router - The router to fetch models from. - * @param apiKey - Optional API key for the provider. - * @param baseUrl - Optional base URL for the provider (currently used only for LiteLLM). + * @param options - Options for fetching models, including the router and any required parameters. * @returns The models from the cache or the fetched models. */ -export const getModels = async ( - router: RouterName, - apiKey: string | undefined = undefined, - baseUrl: string | undefined = undefined, -): Promise => { - let models = memoryCache.get(router) - +export const getModels = async (options: GetModelsOptions): Promise => { + const { provider } = options + let models = memoryCache.get(provider) if (models) { - // console.log(`[getModels] NodeCache hit for ${router} -> ${Object.keys(models).length}`) - return models - } - - switch (router) { - case "openrouter": - models = await getOpenRouterModels() - break - case "requesty": - // Requesty models endpoint requires an API key for per-user custom policies - models = await getRequestyModels(apiKey) - break - case "glama": - models = await getGlamaModels() - break - case "unbound": - // Unbound models endpoint requires an API key to fetch application specific models - models = await getUnboundModels(apiKey) - break - case "litellm": - if (apiKey && baseUrl) { - models = await getLiteLLMModels(apiKey, baseUrl) - } else { - models = {} - } - break - } - - if (Object.keys(models).length > 0) { - // console.log(`[getModels] API fetch for ${router} -> ${Object.keys(models).length}`) - memoryCache.set(router, models) - - try { - await writeModels(router, models) - // console.log(`[getModels] wrote ${router} models to file cache`) - } catch (error) { - console.error(`[getModels] error writing ${router} models to file cache`, error) - } - return models } 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) - } + switch (provider) { + case "openrouter": + models = await getOpenRouterModels() + break + case "requesty": + // Requesty models endpoint requires an API key for per-user custom policies + models = await getRequestyModels(options.apiKey) + break + case "glama": + models = await getGlamaModels() + break + case "unbound": + // Unbound models endpoint requires an API key to fetch application specific models + models = 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) + break + default: + // Ensures router is exhaustively checked if RouterName is a strict union + const exhaustiveCheck: never = provider + throw new Error(`Unknown provider: ${exhaustiveCheck}`) + } - return models ?? {} + // 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 || {} + } 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. + } } -/** - * 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/litellm.ts b/src/api/providers/litellm.ts index be88ede5f6..5b2e938683 100644 --- a/src/api/providers/litellm.ts +++ b/src/api/providers/litellm.ts @@ -19,7 +19,7 @@ export class LiteLLMHandler extends RouterProvider implements SingleCompletionHa options, name: "litellm", baseURL: `${options.litellmBaseUrl || "http://localhost:4000"}`, - apiKey: options.litellmApiKey || "dummy-key", + apiKey: options.litellmApiKey || "sk-1234", modelId: options.litellmModelId, defaultModelId: litellmDefaultModelId, defaultModelInfo: litellmDefaultModelInfo, diff --git a/src/api/providers/lmstudio.ts b/src/api/providers/lmstudio.ts index 0901cb2768..c750c32a26 100644 --- a/src/api/providers/lmstudio.ts +++ b/src/api/providers/lmstudio.ts @@ -25,108 +25,103 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan } override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ - { role: "system", content: systemPrompt }, - ...convertToOpenAiMessages(messages), - ] + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] - // ------------------------- - // Track token usage - // ------------------------- - const toContentBlocks = ( - blocks: Anthropic.Messages.MessageParam[] | string, - ): Anthropic.Messages.ContentBlockParam[] => { - if (typeof blocks === "string") { - return [{ type: "text", text: blocks }] - } + // ------------------------- + // Track token usage + // ------------------------- + const toContentBlocks = ( + blocks: Anthropic.Messages.MessageParam[] | string, + ): Anthropic.Messages.ContentBlockParam[] => { + if (typeof blocks === "string") { + return [{ type: "text", text: blocks }] + } - const result: Anthropic.Messages.ContentBlockParam[] = [] - for (const msg of blocks) { - if (typeof msg.content === "string") { - result.push({ type: "text", text: msg.content }) - } else if (Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === "text") { - result.push({ type: "text", text: part.text }) + const result: Anthropic.Messages.ContentBlockParam[] = [] + for (const msg of blocks) { + if (typeof msg.content === "string") { + result.push({ type: "text", text: msg.content }) + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + if (part.type === "text") { + result.push({ type: "text", text: part.text }) + } } } } - } - return result - } - - let inputTokens = 0 - try { - inputTokens = await this.countTokens([ - { type: "text", text: systemPrompt }, - ...toContentBlocks(messages), - ]) - } catch (err) { - console.error("[LmStudio] Failed to count input tokens:", err) - inputTokens = 0 - } - - let assistantText = "" - - try { - const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming & { draft_model?: string } = { - model: this.getModel().id, - messages: openAiMessages, - temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, - stream: true, + return result } - if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { - params.draft_model = this.options.lmStudioDraftModelId + let inputTokens = 0 + try { + inputTokens = await this.countTokens([{ type: "text", text: systemPrompt }, ...toContentBlocks(messages)]) + } catch (err) { + console.error("[LmStudio] Failed to count input tokens:", err) + inputTokens = 0 } - const results = await this.client.chat.completions.create(params) + let assistantText = "" - const matcher = new XmlMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) + try { + const params: OpenAI.Chat.ChatCompletionCreateParamsStreaming & { draft_model?: string } = { + model: this.getModel().id, + messages: openAiMessages, + temperature: this.options.modelTemperature ?? LMSTUDIO_DEFAULT_TEMPERATURE, + stream: true, + } - for await (const chunk of results) { - const delta = chunk.choices[0]?.delta + if (this.options.lmStudioSpeculativeDecodingEnabled && this.options.lmStudioDraftModelId) { + params.draft_model = this.options.lmStudioDraftModelId + } - if (delta?.content) { - assistantText += delta.content - for (const processedChunk of matcher.update(delta.content)) { - yield processedChunk + const results = await this.client.chat.completions.create(params) + + const matcher = new XmlMatcher( + "think", + (chunk) => + ({ + type: chunk.matched ? "reasoning" : "text", + text: chunk.data, + }) as const, + ) + + for await (const chunk of results) { + const delta = chunk.choices[0]?.delta + + if (delta?.content) { + assistantText += delta.content + for (const processedChunk of matcher.update(delta.content)) { + yield processedChunk + } } } - } - for (const processedChunk of matcher.final()) { - yield processedChunk - } + for (const processedChunk of matcher.final()) { + yield processedChunk + } - - let outputTokens = 0 - try { - outputTokens = await this.countTokens([{ type: "text", text: assistantText }]) - } catch (err) { - console.error("[LmStudio] Failed to count output tokens:", err) - outputTokens = 0 - } + let outputTokens = 0 + try { + outputTokens = await this.countTokens([{ type: "text", text: assistantText }]) + } catch (err) { + console.error("[LmStudio] Failed to count output tokens:", err) + outputTokens = 0 + } - yield { - type: "usage", - inputTokens, - outputTokens, - } as const - } catch (error) { - throw new Error( - "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Roo Code's prompts.", - ) + yield { + type: "usage", + inputTokens, + outputTokens, + } as const + } catch (error) { + throw new Error( + "Please check the LM Studio developer logs to debug what went wrong. You may need to load the model with a larger context length to work with Roo Code's prompts.", + ) + } } -} - override getModel(): { id: string; info: ModelInfo } { return { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 2d9c7f8b8a..8383a423b3 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -171,7 +171,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH public async fetchModel() { const [models, endpoints] = await Promise.all([ - getModels("openrouter"), + getModels({ provider: "openrouter" }), getModelEndpoints({ router: "openrouter", modelId: this.options.openRouterModelId, diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index fe8bba7e6e..130e5be10f 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -45,7 +45,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan } public async fetchModel() { - this.models = await getModels("requesty") + this.models = await getModels({ provider: "requesty", apiKey: this.options.requestyApiKey }) return this.getModel() } diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index a0decdcab4..2a472eb9b4 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -1,6 +1,6 @@ import OpenAI from "openai" -import { ApiHandlerOptions, RouterName, ModelRecord, ModelInfo } from "../../shared/api" +import { ApiHandlerOptions, RouterName, ModelRecord, ModelInfo, GetModelsOptions } from "../../shared/api" import { BaseProvider } from "./base-provider" import { getModels } from "./fetchers/modelCache" @@ -17,6 +17,8 @@ type RouterProviderOptions = { export abstract class RouterProvider extends BaseProvider { protected readonly options: ApiHandlerOptions protected readonly name: RouterName + protected readonly baseURL: string + protected readonly apiKey: string protected models: ModelRecord = {} protected readonly modelId?: string protected readonly defaultModelId: string @@ -39,21 +41,57 @@ export abstract class RouterProvider extends BaseProvider { this.modelId = modelId this.defaultModelId = defaultModelId this.defaultModelInfo = defaultModelInfo + this.baseURL = baseURL + this.apiKey = apiKey - this.client = new OpenAI({ baseURL, apiKey }) + this.client = new OpenAI({ + baseURL, + apiKey, + }) } public async fetchModel() { - this.models = await getModels(this.name, this.client.apiKey, this.client.baseURL) + // Create the appropriate options based on router type + let options: GetModelsOptions + + switch (this.name) { + case "openrouter": + options = { provider: "openrouter" } + break + case "glama": + options = { provider: "glama" } + break + case "requesty": + options = { provider: "requesty", apiKey: this.apiKey } + break + case "unbound": + options = { provider: "unbound", apiKey: this.apiKey } + break + case "litellm": + options = { provider: "litellm", apiKey: this.apiKey, baseUrl: this.baseURL } + break + default: + const exhaustiveCheck: never = this.name + throw new Error(`Unknown provider: ${exhaustiveCheck}`) + } + + this.models = await getModels(options) return this.getModel() } override getModel(): { id: string; info: ModelInfo } { - const id = this.modelId ?? this.defaultModelId + // Try user-specified model first + if (this.modelId && this.models[this.modelId]) { + return { id: this.modelId, info: this.models[this.modelId] } + } - return this.models[id] - ? { id, info: this.models[id] } - : { id: this.defaultModelId, info: this.defaultModelInfo } + // Try default model with fetched info + if (this.models[this.defaultModelId]) { + return { id: this.defaultModelId, info: this.models[this.defaultModelId] } + } + + // Fallback to default model with static info + return { id: this.defaultModelId, info: this.defaultModelInfo } } protected supportsTemperature(modelId: string): boolean { diff --git a/src/core/webview/__tests__/webviewMessageHandler.test.ts b/src/core/webview/__tests__/webviewMessageHandler.test.ts new file mode 100644 index 0000000000..d619bab202 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.test.ts @@ -0,0 +1,176 @@ +// npx jest src/core/webview/__tests__/webviewMessageHandler.test.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import { getModels } from "../../../api/providers/fetchers/modelCache" + +// Mock dependencies +jest.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: jest.fn(), + flushModels: jest.fn().mockResolvedValue(undefined), +})) + +describe("webviewMessageHandler", () => { + // Set up provider mock with essential methods needed by the handler + const mockProvider = { + postMessageToWebview: jest.fn(), + getState: jest.fn().mockResolvedValue({ + apiConfiguration: { + openRouterApiKey: "mock-openrouter-key", + requestyApiKey: "mock-requesty-key", + glamaApiKey: "mock-glama-key", + unboundApiKey: "mock-unbound-key", + litellmApiKey: "mock-litellm-key", + litellmBaseUrl: "https://mock-litellm-url", + }, + }), + log: jest.fn(), + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + describe("requestRouterModels", () => { + test("handles all successful model fetches correctly", async () => { + // Mock all getModels calls to succeed with different data + ;(getModels as jest.Mock).mockImplementation((options) => { + const provider = options.provider + return Promise.resolve({ + [`${provider}-model-1`]: { name: `${provider} Model 1` }, + [`${provider}-model-2`]: { name: `${provider} Model 2` }, + }) + }) + + // Call the handler + await webviewMessageHandler(mockProvider as any, { + type: "requestRouterModels", + }) + + // Verify the provider posted the correct message with all models + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "routerModels", + routerModels: { + openrouter: { + "openrouter-model-1": { name: "openrouter Model 1" }, + "openrouter-model-2": { name: "openrouter Model 2" }, + }, + requesty: { + "requesty-model-1": { name: "requesty Model 1" }, + "requesty-model-2": { name: "requesty Model 2" }, + }, + glama: { + "glama-model-1": { name: "glama Model 1" }, + "glama-model-2": { name: "glama Model 2" }, + }, + unbound: { + "unbound-model-1": { name: "unbound Model 1" }, + "unbound-model-2": { name: "unbound Model 2" }, + }, + litellm: { + "litellm-model-1": { name: "litellm Model 1" }, + "litellm-model-2": { name: "litellm Model 2" }, + }, + }, + }) + }) + + test("handles some failed model fetches correctly", async () => { + // Mock some getModels calls to succeed and others to fail + ;(getModels as jest.Mock).mockImplementation((options) => { + const provider = options.provider + if (provider === "openrouter" || provider === "litellm") { + return Promise.resolve({ + [`${provider}-model-1`]: { name: `${provider} Model 1` }, + }) + } + // For other providers, throw an error + return Promise.reject(new Error(`Failed to fetch ${provider} models`)) + }) + + // Call the handler + await webviewMessageHandler(mockProvider as any, { + type: "requestRouterModels", + }) + + // Verify the provider posted the correct message with only successful models + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "routerModels", + routerModels: { + openrouter: { + "openrouter-model-1": { name: "openrouter Model 1" }, + }, + requesty: {}, + glama: {}, + unbound: {}, + litellm: { + "litellm-model-1": { name: "litellm Model 1" }, + }, + }, + }) + }) + + test("handles all failed model fetches correctly", async () => { + // Mock all getModels calls to fail + ;(getModels as jest.Mock).mockRejectedValue(new Error("API Error")) + + // Call the handler + await webviewMessageHandler(mockProvider as any, { + type: "requestRouterModels", + }) + + // Verify the provider posted the correct message with empty objects for each router + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "routerModels", + routerModels: { + openrouter: {}, + requesty: {}, + glama: {}, + unbound: {}, + litellm: {}, + }, + }) + }) + }) + + describe("requestProviderModels", () => { + test("when getModels succeeds, it posts a providerModelsResponse with models", async () => { + const mockLiteLLMModels = { "litellm-model-1": { name: "LiteLLM Model 1" } } + ;(getModels as jest.Mock).mockResolvedValueOnce(mockLiteLLMModels) + + await webviewMessageHandler(mockProvider as any, { + type: "requestProviderModels", + payload: { provider: "litellm", apiKey: "test-key", baseUrl: "test-url" }, + }) + + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "providerModelsResponse", + payload: { + provider: "litellm", + models: mockLiteLLMModels, + error: undefined, // Explicitly check error is undefined on success + }, + }) + expect(getModels).toHaveBeenCalledWith({ provider: "litellm", apiKey: "test-key", baseUrl: "test-url" }) + }) + + test("when getModels fails, it posts a providerModelsResponse with an error and empty models", async () => { + const errorMessage = "Failed to fetch LiteLLM models: No response from server." + ;(getModels as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)) + + await webviewMessageHandler(mockProvider as any, { + type: "requestProviderModels", + payload: { provider: "litellm", apiKey: "test-key", baseUrl: "test-url" }, + }) + + expect(mockProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "providerModelsResponse", + payload: { + provider: "litellm", + models: {}, + error: errorMessage, + }, + }) + expect(getModels).toHaveBeenCalledWith({ provider: "litellm", apiKey: "test-key", baseUrl: "test-url" }) + }) + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 3cb33561ea..6503530dc9 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -6,7 +6,7 @@ import * as vscode from "vscode" import { ClineProvider } from "./ClineProvider" import { Language, ProviderSettings, GlobalState, Package } from "../../schemas" import { changeLanguage, t } from "../../i18n" -import { RouterName, toRouterName } from "../../shared/api" +import { RouterName, toRouterName, ModelRecord } from "../../shared/api" import { supportPrompt } from "../../shared/support-prompt" import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" import { checkExistKey } from "../../shared/checkExistApiConfig" @@ -31,7 +31,8 @@ import { telemetryService } from "../../services/telemetry/TelemetryService" 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 { flushModels, getModels } from "../../api/providers/fetchers/modelCache" +import { GetModelsOptions } from "../../shared/api" import { generateSystemPrompt } from "./generateSystemPrompt" import { getCommand } from "../../utils/commands" @@ -278,29 +279,117 @@ export const webviewMessageHandler = async (provider: ClineProvider, message: We await provider.resetState() break case "flushRouterModels": - const routerName: RouterName = toRouterName(message.text) - await flushModels(routerName) + const routerNameFlush: RouterName = toRouterName(message.text) + await flushModels(routerNameFlush) break + case "requestProviderModels": { + const optionsFromPayload = message.payload as any // Check payload structure first + + if ( + typeof optionsFromPayload !== "object" || + optionsFromPayload === null || + typeof optionsFromPayload.provider !== "string" || + !optionsFromPayload.provider + ) { + const providerNameForError = + typeof optionsFromPayload?.provider === "string" && optionsFromPayload.provider + ? (optionsFromPayload.provider as RouterName) + : ("unknown" as RouterName) + + provider.postMessageToWebview({ + type: "providerModelsResponse", + payload: { + provider: providerNameForError, + error: "Invalid payload for requestProviderModels: payload must be an object with a valid 'provider' string property.", + }, + }) + break + } + + const options = optionsFromPayload as GetModelsOptions // Now cast to GetModelsOptions + + let models: ModelRecord = {} + let error: string | undefined + + try { + await flushModels(options.provider) + models = await getModels(options) + } catch (e: any) { + error = + e.message || + `Failed to fetch models in webviewMessageHandler requestProviderModels for ${options.provider}. Check console for details.` + models = {} + } + + provider.postMessageToWebview({ + type: "providerModelsResponse", + payload: { provider: options.provider, models, error }, + }) + break + } case "requestRouterModels": const { apiConfiguration } = await provider.getState() - const [openRouterModels, requestyModels, glamaModels, unboundModels, litellmModels] = await Promise.all([ - getModels("openrouter", apiConfiguration.openRouterApiKey), - getModels("requesty", apiConfiguration.requestyApiKey), - getModels("glama", apiConfiguration.glamaApiKey), - getModels("unbound", apiConfiguration.unboundApiKey), - getModels("litellm", apiConfiguration.litellmApiKey, apiConfiguration.litellmBaseUrl), - ]) + 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, + ) + return {} + } + } + + 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 + const litellmBaseUrl = apiConfiguration.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 }) => { + try { + const models = await safeGetModels(options) + return { key, models } + } catch (error) { + console.error(`Outer catch: Error in router models fetch for ${key}:`, error) + return { key, models: {} } + } + }), + ) + + results.forEach((result) => { + if (result.status === "fulfilled") { + routerModels[result.value.key] = result.value.models + } else { + console.error("A model fetching promise was rejected:", result.reason) + } + }) provider.postMessageToWebview({ type: "routerModels", - routerModels: { - openrouter: openRouterModels, - requesty: requestyModels, - glama: glamaModels, - unbound: unboundModels, - litellm: litellmModels, - }, + routerModels: routerModels as Record, }) break case "requestOpenAiModels": diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index da5bb4b2a5..4d1aeeac50 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -300,22 +300,23 @@ export class DiffViewProvider { private async closeAllDiffViews(): Promise { const closeOps = vscode.window.tabGroups.all - .flatMap(group => group.tabs) - .filter( - tab => - tab.input instanceof vscode.TabInputTextDiff && - tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && - !tab.isDirty - ) - .map(tab => - vscode.window.tabGroups.close(tab).then( - () => undefined, - err => { - console.error(`Failed to close diff tab ${tab.label}`, err); - } - )); - - await Promise.all(closeOps); + .flatMap((group) => group.tabs) + .filter( + (tab) => + tab.input instanceof vscode.TabInputTextDiff && + tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && + !tab.isDirty, + ) + .map((tab) => + vscode.window.tabGroups.close(tab).then( + () => undefined, + (err) => { + console.error(`Failed to close diff tab ${tab.label}`, err) + }, + ), + ) + + await Promise.all(closeOps) } private async openDiffEditor(): Promise { @@ -422,7 +423,7 @@ export class DiffViewProvider { return result } - async reset() : Promise { + async reset(): Promise { await this.closeAllDiffViews() this.editType = undefined this.isEditing = false diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index ce23437537..7e4648f145 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -15,7 +15,7 @@ import { } from "../schemas" import { McpServer } from "./mcp" import { Mode } from "./modes" -import { RouterModels } from "./api" +import { RouterModels, ModelRecord, RouterName } from "./api" export type { ProviderSettingsEntry, ToolProgressStatus } @@ -70,6 +70,7 @@ export interface ExtensionMessage { | "commandExecutionStatus" | "vsCodeSetting" | "condenseTaskContextResponse" + | "providerModelsResponse" text?: string action?: | "chatButtonClicked" @@ -108,6 +109,7 @@ export interface ExtensionMessage { error?: string setting?: string value?: any + payload?: ProviderModelsResponsePayload } export type ExtensionState = Pick< @@ -291,3 +293,10 @@ export interface ClineApiReqInfo { } export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled" + +// Payload for providerModelsResponse +export interface ProviderModelsResponsePayload { + provider: RouterName + models?: ModelRecord + error?: string +} diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 6d6c832e75..29060c1428 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -1,6 +1,6 @@ import { z } from "zod" -import { ProviderSettings } from "./api" +import { ProviderSettings, GetModelsOptions } from "./api" import { Mode, PromptComponent, ModeConfig } from "./modes" export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse" @@ -131,6 +131,7 @@ export interface WebviewMessage { | "searchFiles" | "toggleApiConfigPin" | "setHistoryPreviewCollapsed" + | "requestProviderModels" | "condenseTaskContextRequest" text?: string disabled?: boolean @@ -180,4 +181,4 @@ export const checkoutRestorePayloadSchema = z.object({ export type CheckpointRestorePayload = z.infer -export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload +export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload | GetModelsOptions diff --git a/src/shared/api.ts b/src/shared/api.ts index 7350d96f6c..b071501eb5 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1166,7 +1166,7 @@ export const unboundDefaultModelInfo: ModelInfo = { // LiteLLM // https://docs.litellm.ai/ -export const litellmDefaultModelId = "anthropic/claude-3-7-sonnet-20250219" +export const litellmDefaultModelId = "claude-3-7-sonnet-20250219" export const litellmDefaultModelInfo: ModelInfo = { maxTokens: 8192, contextWindow: 200_000, @@ -1832,3 +1832,15 @@ export function toRouterName(value?: string): RouterName { export type ModelRecord = Record export type RouterModels = Record + +/** + * Options for fetching models from different providers. + * This is a discriminated union type where the provider property determines + * which other properties are required. + */ +export type GetModelsOptions = + | { provider: "openrouter" } + | { provider: "glama" } + | { provider: "requesty"; apiKey?: string } + | { provider: "unbound"; apiKey?: string } + | { provider: "litellm"; apiKey: string; baseUrl: string } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 1f45e92a88..377bc752bf 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -1,6 +1,6 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from "react" import { convertHeadersToObject } from "./utils/headers" -import { useDebounce } from "react-use" +import { useDebounce, useEvent } from "react-use" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { @@ -11,6 +11,8 @@ import { glamaDefaultModelId, unboundDefaultModelId, litellmDefaultModelId, + RouterModels, + ModelRecord, } from "@roo/shared/api" import { vscode } from "@src/utils/vscode" @@ -53,6 +55,7 @@ import { TemperatureControl } from "./TemperatureControl" import { RateLimitSecondsControl } from "./RateLimitSecondsControl" import { BedrockCustomArn } from "./providers/BedrockCustomArn" import { buildDocLink } from "@src/utils/docLinks" +import { ExtensionMessage } from "@roo/shared/ExtensionMessage" export interface ApiOptionsProps { uriScheme: string | undefined @@ -63,6 +66,8 @@ export interface ApiOptionsProps { setErrorMessage: React.Dispatch> } +const emptyModelRecord: ModelRecord = {} + const ApiOptions = ({ uriScheme, apiConfiguration, @@ -123,7 +128,44 @@ const ApiOptions = ({ info: selectedModelInfo, } = useSelectedModel(apiConfiguration) - const { data: routerModels, refetch: refetchRouterModels } = useRouterModels() + const { data: initialRouterModels } = useRouterModels() + + const defaultRouterModels: RouterModels = useMemo( + () => ({ + openrouter: emptyModelRecord, + requesty: emptyModelRecord, + glama: emptyModelRecord, + unbound: emptyModelRecord, + litellm: emptyModelRecord, + }), + [], + ) + + const [currentRouterModels, setCurrentRouterModels] = useState( + initialRouterModels || defaultRouterModels, + ) + + useEffect(() => { + if (initialRouterModels) { + setCurrentRouterModels(initialRouterModels) + } else { + setCurrentRouterModels(defaultRouterModels) + } + }, [initialRouterModels, defaultRouterModels]) + + // Listen for specific provider model updates using useEvent + useEvent("message", (event: MessageEvent) => { + const message = event.data + if (message.type === "providerModelsResponse" && message.payload) { + const { provider, models, error } = message.payload + if (provider && models && !error) { + setCurrentRouterModels((prevModels) => ({ + ...prevModels, + [provider]: models, + })) + } + } + }) // Update `apiModelId` whenever `selectedModelId` changes. useEffect(() => { @@ -175,10 +217,10 @@ const ApiOptions = ({ useEffect(() => { const apiValidationResult = - validateApiConfiguration(apiConfiguration) || validateModelId(apiConfiguration, routerModels) + validateApiConfiguration(apiConfiguration) || validateModelId(apiConfiguration, currentRouterModels) setErrorMessage(apiValidationResult) - }, [apiConfiguration, routerModels, setErrorMessage]) + }, [apiConfiguration, currentRouterModels, setErrorMessage]) const selectedProviderModels = useMemo( () => @@ -221,16 +263,29 @@ const ApiOptions = ({ setApiConfigurationField("requestyModelId", requestyDefaultModelId) } break - case "litellm": - if (!apiConfiguration.litellmModelId) { + case "litellm": { + let currentLitellmModelId = apiConfiguration.litellmModelId + if (!currentLitellmModelId) { setApiConfigurationField("litellmModelId", litellmDefaultModelId) + currentLitellmModelId = litellmDefaultModelId // Use the default for the next step + } + // Ensure apiModelId is also set to the specific litellm model id + if (apiConfiguration.apiModelId !== currentLitellmModelId) { + setApiConfigurationField("apiModelId", currentLitellmModelId) } break + } } - setApiConfigurationField("apiProvider", value) + // Only update the apiProvider if it's actually changing. + // This should be called after model-specific IDs are handled for the new provider. + if (apiConfiguration.apiProvider !== value) { + setApiConfigurationField("apiProvider", value) + } }, [ + apiConfiguration.apiProvider, + apiConfiguration.apiModelId, // Add apiModelId as it's read and potentially set setApiConfigurationField, apiConfiguration.openRouterModelId, apiConfiguration.glamaModelId, @@ -294,7 +349,7 @@ const ApiOptions = ({ )} @@ -314,7 +368,7 @@ const ApiOptions = ({ )} @@ -323,7 +377,7 @@ const ApiOptions = ({ )} @@ -394,7 +448,7 @@ const ApiOptions = ({ )} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index f9c2425222..d4baa450e5 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -27,7 +27,7 @@ import { import { ExperimentId } from "@roo/shared/experiments" import { TelemetrySetting } from "@roo/shared/TelemetrySetting" -import { ProviderSettings } from "@roo/shared/api" +import { ProviderSettings, litellmDefaultModelId } from "@roo/shared/api" import { vscode } from "@/utils/vscode" import { ExtensionStateContextType, useExtensionState } from "@/context/ExtensionStateContext" @@ -220,6 +220,51 @@ const SettingsView = forwardRef(({ onDone, t }) }, []) + useEffect(() => { + // This effect ensures that if LiteLLM is the provider, + // its specific configuration fields have default values in the cached state. + const config = cachedState.apiConfiguration + if (config && config.apiProvider === "litellm") { + const baseUrlMissing = config.litellmBaseUrl === undefined + const apiKeyMissing = config.litellmApiKey === undefined + + if (baseUrlMissing || apiKeyMissing) { + setCachedState((prevState) => { + // Ensure we are working with the latest apiConfiguration from prevState + const currentApiConfig = prevState.apiConfiguration ?? {} + let updatedApiConfig = { ...currentApiConfig } // Clone to modify + let madeChanges = false + + // Check and apply defaults based on the fresh currentApiConfig + if (currentApiConfig.litellmBaseUrl === undefined) { + updatedApiConfig.litellmBaseUrl = "http://localhost:4000" + madeChanges = true + } + if (currentApiConfig.litellmApiKey === undefined) { + updatedApiConfig.litellmApiKey = "sk-1234" + madeChanges = true + } + if (currentApiConfig.litellmModelId === undefined) { + updatedApiConfig.litellmModelId = litellmDefaultModelId + madeChanges = true + } + + if (madeChanges) { + // setChangeDetected is not directly available here unless passed to setCachedState's scope or handled differently + // For now, we focus on updating apiConfiguration correctly. + // The parent component (SettingsView) already calls setChangeDetected(true) when setApiConfigurationField is used. + // If we bypass setApiConfigurationField, we need to call setChangeDetected here. + setChangeDetected(true) // Call setChangeDetected as we are modifying the state that tracks changes. + return { ...prevState, apiConfiguration: updatedApiConfig } + } + return prevState // No changes were actually needed based on the fresh check + }) + } + } + // Adding setCachedState and setChangeDetected to dependencies as they are used directly or indirectly. + // setApiConfigurationField is removed as we are not calling it from here for these specific defaults. + }, [cachedState.apiConfiguration, setCachedState, setChangeDetected]) + const setTelemetrySetting = useCallback((setting: TelemetrySetting) => { setCachedState((prevState) => { if (prevState.telemetrySetting === setting) { diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx index d9d8ff2a66..14a03e7c81 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.test.tsx @@ -40,8 +40,8 @@ jest.mock("vscrui", () => ({ // Mock @shadcn/ui components jest.mock("@/components/ui", () => ({ - Select: ({ children, value, onValueChange }: any) => ( -
+ Select: ({ children, value, onValueChange, ...rest }: any) => ( +
@@ -151,11 +151,13 @@ jest.mock("@src/components/ui/hooks/useSelectedModel", () => ({ if (apiConfiguration.apiModelId?.includes("thinking")) { return { provider: apiConfiguration.apiProvider, + id: apiConfiguration.apiModelId, info: { thinking: true, contextWindow: 4000, maxTokens: 128000 }, } } else { return { provider: apiConfiguration.apiProvider, + id: apiConfiguration.apiModelId, info: { contextWindow: 4000 }, } } diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx index 072296a754..46acab8b92 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.test.tsx @@ -134,6 +134,14 @@ jest.mock("@/components/ui", () => ({ ), })) +// Mock ApiOptions to inspect its props +jest.mock("../ApiOptions", () => ({ + __esModule: true, + default: jest.fn((props) => ( +
+ )), +})) + // Mock window.postMessage to trigger state hydration const mockPostMessage = (state: any) => { window.postMessage( @@ -369,13 +377,87 @@ describe("SettingsView - Sound Settings", () => { describe("SettingsView - API Configuration", () => { beforeEach(() => { jest.clearAllMocks() + // Reset ApiOptions mock calls before each test if needed + require("../ApiOptions").default.mockClear() }) - it("renders ApiConfigManagement with correct props", () => { + it("renders ApiConfigManager with correct props", () => { renderSettingsView() expect(screen.getByTestId("api-config-management")).toBeInTheDocument() }) + + it("defaults LiteLLM fields in apiConfiguration if provider is litellm and fields are missing", async () => { + const initialExtensionState = { + apiConfiguration: { + apiProvider: "litellm", + // litellmBaseUrl, litellmApiKey, litellmModelId are missing + }, + // Add other necessary state fields for useExtensionState mock + currentApiConfigName: "default", + listApiConfigMeta: [], + uriScheme: "vscode", + version: "1.0.0", + settingsImportedAt: null, + } + + // Mock useExtensionState to return our initial state + const mockUseExtensionState = jest.spyOn(require("@/context/ExtensionStateContext"), "useExtensionState") + mockUseExtensionState.mockReturnValue(initialExtensionState) + + const { activateTab } = renderSettingsView() // onDone is part of the return, but we don't need it here + + // Ensure providers tab is active (it should be by default, but explicit doesn't hurt) + activateTab("providers") + + // Wait for effects to run. Finding the mocked ApiOptions is a good way to ensure it has rendered with updated props. + const apiOptionsMock = await screen.findByTestId("api-options-mock") + const passedApiConfigString = apiOptionsMock.getAttribute("data-apiconfiguration") + const passedApiConfig = JSON.parse(passedApiConfigString!) + + expect(passedApiConfig.apiProvider).toBe("litellm") + expect(passedApiConfig.litellmBaseUrl).toBe("http://localhost:4000") + expect(passedApiConfig.litellmApiKey).toBe("sk-1234") + expect(passedApiConfig.litellmModelId).toBeDefined() // Check it's defined (actual value is litellmDefaultModelId) + + mockUseExtensionState.mockRestore() + }) + + it("preserves existing LiteLLM fields in apiConfiguration if provider is litellm", async () => { + const myCustomKey = "my-custom-key" + const myCustomUrl = "http://my-custom-url.com" + const myCustomModel = "custom-model/my-model" + const initialExtensionState = { + apiConfiguration: { + apiProvider: "litellm", + litellmBaseUrl: myCustomUrl, + litellmApiKey: myCustomKey, + litellmModelId: myCustomModel, + }, + currentApiConfigName: "default", + listApiConfigMeta: [], + uriScheme: "vscode", + version: "1.0.0", + settingsImportedAt: null, + } + + const mockUseExtensionState = jest.spyOn(require("@/context/ExtensionStateContext"), "useExtensionState") + mockUseExtensionState.mockReturnValue(initialExtensionState) + + const { activateTab } = renderSettingsView() + activateTab("providers") + + const apiOptionsMock = await screen.findByTestId("api-options-mock") + const passedApiConfigString = apiOptionsMock.getAttribute("data-apiconfiguration") + const passedApiConfig = JSON.parse(passedApiConfigString!) + + expect(passedApiConfig.apiProvider).toBe("litellm") + expect(passedApiConfig.litellmBaseUrl).toBe(myCustomUrl) + expect(passedApiConfig.litellmApiKey).toBe(myCustomKey) + expect(passedApiConfig.litellmModelId).toBe(myCustomModel) + + mockUseExtensionState.mockRestore() + }) }) describe("SettingsView - Allowed Commands", () => { diff --git a/webview-ui/src/components/settings/providers/LiteLLM.tsx b/webview-ui/src/components/settings/providers/LiteLLM.tsx index 8f98d97e5d..0dbcbc1385 100644 --- a/webview-ui/src/components/settings/providers/LiteLLM.tsx +++ b/webview-ui/src/components/settings/providers/LiteLLM.tsx @@ -1,21 +1,30 @@ -import { useCallback } from "react" +import { useCallback, useState, useEffect, useRef } from "react" import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" +import { useEvent } from "react-use" import { ProviderSettings, RouterModels, litellmDefaultModelId } from "@roo/shared/api" - +import { vscode } from "@src/utils/vscode" +import { Button } from "@src/components/ui" import { useAppTranslation } from "@src/i18n/TranslationContext" import { inputEventTransform } from "../transforms" import { ModelPicker } from "../ModelPicker" +import { WebviewMessage } from "@roo/shared/WebviewMessage" +import { ExtensionMessage } from "@roo/shared/ExtensionMessage" -type LiteLLMProps = { +export type LiteLLMProps = { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + // routerModels prop might need to be updated by parent if we want to show new models immediately. + // For now, this component will manage its own refresh feedback. routerModels?: RouterModels } export const LiteLLM = ({ apiConfiguration, setApiConfigurationField, routerModels }: LiteLLMProps) => { const { t } = useAppTranslation() + const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshError, setRefreshError] = useState() + const initialRefreshPerformedRef = useRef(false) const handleInputChange = useCallback( ( @@ -28,10 +37,80 @@ export const LiteLLM = ({ apiConfiguration, setApiConfigurationField, routerMode [setApiConfigurationField], ) + const handleRefreshModels = useCallback(() => { + setRefreshStatus("loading") + setRefreshError(undefined) + + const key = apiConfiguration.litellmApiKey + const url = apiConfiguration.litellmBaseUrl + + if (!key || !url) { + setRefreshStatus("error") + setRefreshError(t("settings:providers.refreshModels.missingConfig")) + return + } + + const message: WebviewMessage = { + type: "requestProviderModels", + payload: { + provider: "litellm", + apiKey: key, + baseUrl: url, + }, + } + vscode.postMessage(message) + }, [apiConfiguration.litellmApiKey, apiConfiguration.litellmBaseUrl, setRefreshStatus, setRefreshError, t]) + + // Effect to trigger initial model refresh, once per component instance when conditions are met + useEffect(() => { + // Only proceed if the initial refresh for this component instance hasn't been done + if (initialRefreshPerformedRef.current) { + return + } + + // Check if the necessary configuration is available + if (apiConfiguration.litellmApiKey && apiConfiguration.litellmBaseUrl) { + // Mark that we are performing the refresh for this instance + initialRefreshPerformedRef.current = true + // Directly execute refresh logic + setRefreshStatus("loading") + setRefreshError(undefined) + const message: WebviewMessage = { + type: "requestProviderModels", + payload: { + provider: "litellm", + apiKey: apiConfiguration.litellmApiKey, + baseUrl: apiConfiguration.litellmBaseUrl, + }, + } + vscode.postMessage(message) + } + }, [apiConfiguration.litellmApiKey, apiConfiguration.litellmBaseUrl]) + + useEvent("message", (event: MessageEvent) => { + const message = event.data + if (message.type === "providerModelsResponse") { + if (message.payload && message.payload.provider === "litellm") { + if (message.payload.error) { + console.log("LiteLLM.tsx: Error found in payload:", message.payload.error) + setRefreshStatus("error") + setRefreshError(message.payload.error) + } else { + setRefreshStatus("success") + } + } else { + console.log( + "LiteLLM.tsx: Received providerModelsResponse but not for litellm or payload missing. Provider:", + message.payload?.provider, + ) + } + } + }) + return ( <> @@ -51,6 +130,36 @@ export const LiteLLM = ({ apiConfiguration, setApiConfigurationField, routerMode {t("settings:providers.apiKeyStorageNotice")}
+ + {refreshStatus === "loading" && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === "success" && ( +
{t("settings:providers.refreshModels.success")}
+ )} + {refreshStatus === "error" && ( +
+ {refreshError || t("settings:providers.refreshModels.error")} +
+ )} + void routerModels?: RouterModels - refetchRouterModels: () => void } -export const Requesty = ({ - apiConfiguration, - setApiConfigurationField, - routerModels, - refetchRouterModels, -}: RequestyProps) => { +export const Requesty = ({ apiConfiguration, setApiConfigurationField, routerModels }: RequestyProps) => { const { t } = useAppTranslation() - - const [didRefetch, setDidRefetch] = useState() + const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [refreshError, setRefreshError] = useState() const handleInputChange = useCallback( ( @@ -40,6 +36,32 @@ export const Requesty = ({ [setApiConfigurationField], ) + const handleRefreshModels = () => { + setRefreshStatus("loading") + setRefreshError(undefined) + const message: WebviewMessage = { + type: "requestProviderModels", + payload: { + provider: "requesty", + apiKey: apiConfiguration.requestyApiKey, + }, + } + vscode.postMessage(message) + } + + useEvent("message", (event: MessageEvent) => { + const message = event.data + if (message.type === "providerModelsResponse" && message.payload && message.payload.provider === "requesty") { + const payload = message.payload as ProviderModelsResponsePayload + if (payload.error) { + setRefreshStatus("error") + setRefreshError(payload.error) + } else { + setRefreshStatus("success") + } + } + }) + return ( <> { - vscode.postMessage({ type: "flushRouterModels", text: "requesty" }) - refetchRouterModels() - setDidRefetch(true) - }}> + onClick={handleRefreshModels} + disabled={refreshStatus === "loading"} + className="w-full">
- + {refreshStatus === "loading" ? ( + + ) : ( + + )} {t("settings:providers.refreshModels.label")}
- {didRefetch && ( -
- {t("settings:providers.refreshModels.hint")} + {refreshStatus === "loading" && ( +
+ {t("settings:providers.refreshModels.loading")} +
+ )} + {refreshStatus === "success" && ( +
{t("settings:providers.refreshModels.success")}
+ )} + {refreshStatus === "error" && ( +
+ {refreshError || t("settings:providers.refreshModels.error")}
)} ({ + vscode: { + postMessage: jest.fn(), + }, +})) + +// Mock VSCodeTextField +jest.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children, value, onInput, type }: any) => ( +
+ {children} + onInput && onInput({ target: { value: e.target.value } })} + /> +
+ ), +})) + +// Mock ModelPicker +jest.mock("../../ModelPicker", () => ({ + ModelPicker: () =>
ModelPicker
, +})) + +const mockT = jest.fn((key) => key) // Simple t mock + +jest.mock("@/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: mockT, + }), +})) + +const defaultProps: LiteLLMProps = { + apiConfiguration: { litellmApiKey: "", litellmBaseUrl: "" }, + setApiConfigurationField: jest.fn(), + routerModels: { + litellm: {}, + glama: {}, + openrouter: {}, + unbound: {}, + requesty: {}, + }, +} + +const renderLiteLLM = (props?: Partial) => { + return render( + + + , + ) +} + +describe("LiteLLM Component", () => { + beforeEach(() => { + jest.clearAllMocks() + // Reset the ref module-level if needed, but usually refs are instance-based. + // For this test, we rely on fresh mounts giving fresh refs. + }) + + it("does not attempt initial model refresh if API key is missing", () => { + renderLiteLLM({ + apiConfiguration: { + ...defaultProps.apiConfiguration, + litellmBaseUrl: "http://localhost:4000", + litellmApiKey: "", + }, + }) + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "requestProviderModels" })) + }) + + it("does not attempt initial model refresh if base URL is missing", () => { + renderLiteLLM({ + apiConfiguration: { ...defaultProps.apiConfiguration, litellmApiKey: "test-key", litellmBaseUrl: "" }, + }) + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "requestProviderModels" })) + }) + + it("attempts initial model refresh once if API key and base URL are present on mount", () => { + renderLiteLLM({ + apiConfiguration: { + ...defaultProps.apiConfiguration, + litellmApiKey: "test-key", + litellmBaseUrl: "http://localhost:4000", + }, + }) + expect(vscode.postMessage).toHaveBeenCalledTimes(1) + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "requestProviderModels", + payload: { + provider: "litellm", + apiKey: "test-key", + baseUrl: "http://localhost:4000", + }, + }) + }) + + it("does not re-attempt initial refresh if props change but refresh was already done", () => { + const { rerender } = renderLiteLLM({ + apiConfiguration: { + ...defaultProps.apiConfiguration, + litellmApiKey: "test-key", + litellmBaseUrl: "http://localhost:4000", + }, + }) + expect(vscode.postMessage).toHaveBeenCalledTimes(1) // Initial call + + // Re-render with different routerModels (a prop that might change) + rerender( + + + , + ) + // Should still only be 1 call from the initial refresh + expect(vscode.postMessage).toHaveBeenCalledTimes(1) + }) + + it("manual refresh button is disabled if API key is missing", () => { + renderLiteLLM({ + apiConfiguration: { + ...defaultProps.apiConfiguration, + litellmBaseUrl: "http://localhost:4000", + litellmApiKey: "", + }, + }) + const refreshButton = screen.getByText("settings:providers.refreshModels.label").closest("button") + expect(refreshButton).toBeDisabled() + }) + + it("manual refresh button is disabled if base URL is missing", () => { + renderLiteLLM({ + apiConfiguration: { ...defaultProps.apiConfiguration, litellmApiKey: "test-key", litellmBaseUrl: "" }, + }) + const refreshButton = screen.getByText("settings:providers.refreshModels.label").closest("button") + expect(refreshButton).toBeDisabled() + }) +}) diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index 536a654844..2836f95821 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react" +import { useCallback, useState, useEffect } from "react" import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { useExtensionState } from "@src/context/ExtensionStateContext" import { validateApiConfiguration } from "@src/utils/validate" @@ -10,12 +10,67 @@ import { useAppTranslation } from "@src/i18n/TranslationContext" import { getRequestyAuthUrl, getOpenRouterAuthUrl } from "@src/oauth/urls" import RooHero from "./RooHero" import knuthShuffle from "knuth-shuffle-seeded" +import { ProviderSettings, litellmDefaultModelId } from "@roo/shared/api" const WelcomeView = () => { const { apiConfiguration, currentApiConfigName, setApiConfiguration, uriScheme, machineId } = useExtensionState() const { t } = useAppTranslation() const [errorMessage, setErrorMessage] = useState(undefined) + // Memoize the setApiConfigurationField function to pass to ApiOptions + const setApiConfigurationFieldForApiOptions = useCallback( + (field: K, value: ProviderSettings[K]) => { + setApiConfiguration({ [field]: value }) + }, + [setApiConfiguration], // setApiConfiguration from context is stable + ) + + useEffect(() => { + if (!apiConfiguration) { + // If no apiConfig at all, nothing to default yet. + // This can happen before the initial state hydration from the extension. + return + } + + const currentProvider = apiConfiguration.apiProvider + // Needs provider default if apiProvider is undefined, null, or an empty string. + const needsProviderDefault = !currentProvider + const isLiteLLMSelectedOrShouldBeDefault = currentProvider === "litellm" || needsProviderDefault + + if (isLiteLLMSelectedOrShouldBeDefault) { + const updates: Partial = {} + let madeChanges = false + + if (apiConfiguration.litellmBaseUrl === undefined) { + updates.litellmBaseUrl = "http://localhost:4000" + madeChanges = true + } + if (apiConfiguration.litellmApiKey === undefined) { + updates.litellmApiKey = "sk-1234" + madeChanges = true + } + if (apiConfiguration.litellmModelId === undefined) { + updates.litellmModelId = litellmDefaultModelId + madeChanges = true + } + + // If apiProvider was initially missing or falsy, set it to "litellm". + if (needsProviderDefault) { + updates.apiProvider = "litellm" + madeChanges = true + } + + if (madeChanges) { + // This log helps confirm if we are about to call setApiConfiguration + setApiConfiguration(updates) + } else { + // This log helps confirm that on subsequent runs, no changes are deemed necessary. + } + } + }, [apiConfiguration, setApiConfiguration]) + + useEffect(() => {}, [apiConfiguration]) + const handleSubmit = useCallback(() => { const error = apiConfiguration ? validateApiConfiguration(apiConfiguration) : undefined @@ -106,7 +161,7 @@ const WelcomeView = () => { fromWelcomeView apiConfiguration={apiConfiguration || {}} uriScheme={uriScheme} - setApiConfigurationField={(field, value) => setApiConfiguration({ [field]: value })} + setApiConfigurationField={setApiConfigurationFieldForApiOptions} errorMessage={errorMessage} setErrorMessage={setErrorMessage} /> diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 4194c9b11f..626ea7b115 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -190,6 +190,16 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode [], ) + const setApiConfiguration = useCallback((value: ProviderSettings) => { + setState((prevState) => ({ + ...prevState, + apiConfiguration: { + ...prevState.apiConfiguration, + ...value, + }, + })) + }, []) + const handleMessage = useCallback( (event: MessageEvent) => { const message: ExtensionMessage = event.data @@ -268,14 +278,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode screenshotQuality: state.screenshotQuality, setExperimentEnabled: (id, enabled) => setState((prevState) => ({ ...prevState, experiments: { ...prevState.experiments, [id]: enabled } })), - setApiConfiguration: (value) => - setState((prevState) => ({ - ...prevState, - apiConfiguration: { - ...prevState.apiConfiguration, - ...value, - }, - })), + setApiConfiguration, setCustomInstructions: (value) => setState((prevState) => ({ ...prevState, customInstructions: value })), setAlwaysAllowReadOnly: (value) => setState((prevState) => ({ ...prevState, alwaysAllowReadOnly: value })), setAlwaysAllowReadOnlyOutsideWorkspace: (value) => diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 0dca22bc49..24e691086c 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Clau API de Requesty", "refreshModels": { "label": "Actualitzar models", - "hint": "Si us plau, torneu a obrir la configuració per veure els models més recents." + "hint": "Si us plau, torneu a obrir la configuració per veure els models més recents.", + "loading": "Actualitzant models...", + "success": "Models actualitzats correctament.", + "error": "No s'han pogut actualitzar els models. Si us plau, comproveu la vostra configuració i torneu-ho a provar.", + "missingConfig": "Falta la clau API o l'URL base. Si us plau, proporcioneu ambdós per actualitzar els models." }, "getRequestyApiKey": "Obtenir clau API de Requesty", "openRouterTransformsText": "Comprimir prompts i cadenes de missatges a la mida del context (Transformacions d'OpenRouter)", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 0874415c21..9423509122 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty API-Schlüssel", "refreshModels": { "label": "Modelle aktualisieren", - "hint": "Bitte öffne die Einstellungen erneut, um die neuesten Modelle zu sehen." + "hint": "Bitte öffne die Einstellungen erneut, um die neuesten Modelle zu sehen.", + "loading": "Modelle werden aktualisiert...", + "success": "Modelle erfolgreich aktualisiert.", + "error": "Fehler beim Aktualisieren der Modelle. Bitte überprüfe deine Konfiguration und versuche es erneut.", + "missingConfig": "API-Schlüssel oder Basis-URL fehlt. Bitte gib beides an, um Modelle zu aktualisieren." }, "getRequestyApiKey": "Requesty API-Schlüssel erhalten", "openRouterTransformsText": "Prompts und Nachrichtenketten auf Kontextgröße komprimieren (OpenRouter Transformationen)", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 6fa457ab8f..af4c335ef4 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty API Key", "refreshModels": { "label": "Refresh Models", - "hint": "Please reopen the settings to see the latest models." + "hint": "Please reopen the settings to see the latest models.", + "loading": "Refreshing models...", + "success": "Models refreshed successfully.", + "error": "Failed to refresh models. Please check your configuration and try again.", + "missingConfig": "API key or base URL missing. Please provide both to refresh models." }, "getRequestyApiKey": "Get Requesty API Key", "openRouterTransformsText": "Compress prompts and message chains to the context size (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 43b23668c0..39be5abf9d 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Clave API de Requesty", "refreshModels": { "label": "Actualizar modelos", - "hint": "Por favor, vuelve a abrir la configuración para ver los modelos más recientes." + "hint": "Por favor, vuelve a abrir la configuración para ver los modelos más recientes.", + "loading": "Actualizando modelos...", + "success": "Modelos actualizados correctamente.", + "error": "Error al actualizar los modelos. Por favor, verifica tu configuración e inténtalo de nuevo.", + "missingConfig": "Falta la clave API o la URL base. Por favor, proporciona ambos para actualizar los modelos." }, "getRequestyApiKey": "Obtener clave API de Requesty", "openRouterTransformsText": "Comprimir prompts y cadenas de mensajes al tamaño del contexto (Transformaciones de OpenRouter)", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index c129a22c31..f25878eb03 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Clé API Requesty", "refreshModels": { "label": "Actualiser les modèles", - "hint": "Veuillez rouvrir les paramètres pour voir les modèles les plus récents." + "hint": "Veuillez rouvrir les paramètres pour voir les modèles les plus récents.", + "loading": "Actualisation des modèles...", + "success": "Modèles actualisés avec succès.", + "error": "Échec de l'actualisation des modèles. Veuillez vérifier votre configuration et réessayer.", + "missingConfig": "Clé API ou URL de base manquante. Veuillez fournir les deux pour actualiser les modèles." }, "getRequestyApiKey": "Obtenir la clé API Requesty", "openRouterTransformsText": "Compresser les prompts et chaînes de messages à la taille du contexte (Transformations OpenRouter)", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 3b35311e9e..ef15d4b86d 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty API कुंजी", "refreshModels": { "label": "मॉडल रिफ्रेश करें", - "hint": "नवीनतम मॉडल देखने के लिए कृपया सेटिंग्स को फिर से खोलें।" + "hint": "नवीनतम मॉडल देखने के लिए कृपया सेटिंग्स को फिर से खोलें।", + "loading": "मॉडल रिफ्रेश हो रहे हैं...", + "success": "मॉडल सफलतापूर्वक रिफ्रेश हो गए।", + "error": "मॉडल रिफ्रेश करने में विफल। कृपया अपनी कॉन्फ़िगरेशन जांचें और पुनः प्रयास करें।", + "missingConfig": "API कुंजी या बेस URL अनुपलब्ध है। मॉडल रिफ्रेश करने के लिए कृपया दोनों प्रदान करें।" }, "getRequestyApiKey": "Requesty API कुंजी प्राप्त करें", "openRouterTransformsText": "संदर्भ आकार के लिए प्रॉम्प्ट और संदेश श्रृंखलाओं को संपीड़ित करें (OpenRouter ट्रांसफॉर्म)", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 52cadf7287..55624c2b76 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Chiave API Requesty", "refreshModels": { "label": "Aggiorna modelli", - "hint": "Riapri le impostazioni per vedere i modelli più recenti." + "hint": "Riapri le impostazioni per vedere i modelli più recenti.", + "loading": "Aggiornamento modelli in corso...", + "success": "Modelli aggiornati con successo.", + "error": "Impossibile aggiornare i modelli. Verifica la tua configurazione e riprova.", + "missingConfig": "Chiave API o URL base mancante. Fornisci entrambi per aggiornare i modelli." }, "getRequestyApiKey": "Ottieni chiave API Requesty", "openRouterTransformsText": "Comprimi prompt e catene di messaggi alla dimensione del contesto (Trasformazioni OpenRouter)", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 32dbbe3bc6..1e97ee68fc 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty APIキー", "refreshModels": { "label": "モデルを更新", - "hint": "最新のモデルを表示するには設定を再度開いてください。" + "hint": "最新のモデルを表示するには設定を再度開いてください。", + "loading": "モデルを更新中...", + "success": "モデルが正常に更新されました。", + "error": "モデルの更新に失敗しました。設定を確認して再試行してください。", + "missingConfig": "APIキーまたはベースURLが不足しています。モデルを更新するには両方を提供してください。" }, "getRequestyApiKey": "Requesty APIキーを取得", "openRouterTransformsText": "プロンプトとメッセージチェーンをコンテキストサイズに圧縮 (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 5087853391..000bd2ca7b 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty API 키", "refreshModels": { "label": "모델 새로고침", - "hint": "최신 모델을 보려면 설정을 다시 열어주세요." + "hint": "최신 모델을 보려면 설정을 다시 열어주세요.", + "loading": "모델 새로고침 중...", + "success": "모델이 성공적으로 새로고침되었습니다.", + "error": "모델 새로고침에 실패했습니다. 설정을 확인하고 다시 시도해주세요.", + "missingConfig": "API 키 또는 기본 URL이 누락되었습니다. 모델을 새로고침하려면 둘 다 제공해주세요." }, "getRequestyApiKey": "Requesty API 키 받기", "openRouterTransformsText": "프롬프트와 메시지 체인을 컨텍스트 크기로 압축 (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 10b8bd2ad4..bf39d88678 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty API-sleutel", "refreshModels": { "label": "Modellen verversen", - "hint": "Open de instellingen opnieuw om de nieuwste modellen te zien." + "hint": "Open de instellingen opnieuw om de nieuwste modellen te zien.", + "loading": "Modellen verversen...", + "success": "Modellen succesvol ververst.", + "error": "Modellen verversen mislukt. Controleer je configuratie en probeer het opnieuw.", + "missingConfig": "API-sleutel of basis-URL ontbreekt. Geef beide op om modellen te verversen." }, "getRequestyApiKey": "Requesty API-sleutel ophalen", "openRouterTransformsText": "Comprimeer prompts en berichtreeksen tot de contextgrootte (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 39160b5a67..2fd9f53c05 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Klucz API Requesty", "refreshModels": { "label": "Odśwież modele", - "hint": "Proszę ponownie otworzyć ustawienia, aby zobaczyć najnowsze modele." + "hint": "Proszę ponownie otworzyć ustawienia, aby zobaczyć najnowsze modele.", + "loading": "Odświeżanie modeli...", + "success": "Modele zostały pomyślnie odświeżone.", + "error": "Nie udało się odświeżyć modeli. Sprawdź konfigurację i spróbuj ponownie.", + "missingConfig": "Brak klucza API lub podstawowego URL. Podaj oba, aby odświeżyć modele." }, "getRequestyApiKey": "Uzyskaj klucz API Requesty", "openRouterTransformsText": "Kompresuj podpowiedzi i łańcuchy wiadomości do rozmiaru kontekstu (Transformacje OpenRouter)", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index ccf1e422a9..96db5cf660 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Chave de API Requesty", "refreshModels": { "label": "Atualizar modelos", - "hint": "Por favor, reabra as configurações para ver os modelos mais recentes." + "hint": "Por favor, reabra as configurações para ver os modelos mais recentes.", + "loading": "Atualizando modelos...", + "success": "Modelos atualizados com sucesso.", + "error": "Falha ao atualizar modelos. Verifique sua configuração e tente novamente.", + "missingConfig": "Chave API ou URL base ausente. Por favor, forneça ambos para atualizar os modelos." }, "getRequestyApiKey": "Obter chave de API Requesty", "openRouterTransformsText": "Comprimir prompts e cadeias de mensagens para o tamanho do contexto (Transformações OpenRouter)", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index b73e37bdc1..14256b16a7 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty API-ключ", "refreshModels": { "label": "Обновить модели", - "hint": "Пожалуйста, откройте настройки заново, чтобы увидеть последние модели." + "hint": "Пожалуйста, откройте настройки заново, чтобы увидеть последние модели.", + "loading": "Обновление моделей...", + "success": "Модели успешно обновлены.", + "error": "Не удалось обновить модели. Пожалуйста, проверьте вашу конфигурацию и попробуйте снова.", + "missingConfig": "Отсутствует API-ключ или базовый URL. Пожалуйста, укажите оба параметра для обновления моделей." }, "getRequestyApiKey": "Получить Requesty API-ключ", "openRouterTransformsText": "Сжимать подсказки и цепочки сообщений до размера контекста (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 2b192e9813..1a75716a6c 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty API Anahtarı", "refreshModels": { "label": "Modelleri Yenile", - "hint": "En son modelleri görmek için lütfen ayarları yeniden açın." + "hint": "En son modelleri görmek için lütfen ayarları yeniden açın.", + "loading": "Modeller yenileniyor...", + "success": "Modeller başarıyla yenilendi.", + "error": "Modeller yenilenemedi. Lütfen yapılandırmanızı kontrol edin ve tekrar deneyin.", + "missingConfig": "API anahtarı veya temel URL eksik. Modelleri yenilemek için lütfen her ikisini de sağlayın." }, "getRequestyApiKey": "Requesty API Anahtarı Al", "openRouterTransformsText": "İstem ve mesaj zincirlerini bağlam boyutuna sıkıştır (OpenRouter Dönüşümleri)", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4c39eb62b0..586ea2190b 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Khóa API Requesty", "refreshModels": { "label": "Làm mới mô hình", - "hint": "Vui lòng mở lại cài đặt để xem các mô hình mới nhất." + "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 mô hình...", + "success": "Làm mới mô hình thành công.", + "error": "Không thể làm mới mô hình. Vui lòng kiểm tra cấu hình của bạn và thử lại.", + "missingConfig": "Thiếu khóa API hoặc URL cơ sở. Vui lòng cung cấp cả hai để làm mới mô hình." }, "getRequestyApiKey": "Lấy khóa API Requesty", "openRouterTransformsText": "Nén lời nhắc và chuỗi tin nhắn theo kích thước ngữ cảnh (OpenRouter Transforms)", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 47bd13a2ba..fbe920abaa 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty API 密钥", "refreshModels": { "label": "刷新模型", - "hint": "请重新打开设置以查看最新模型。" + "hint": "请重新打开设置以查看最新模型。", + "loading": "正在刷新模型...", + "success": "模型刷新成功。", + "error": "刷新模型失败。请检查您的配置并重试。", + "missingConfig": "缺少 API 密钥或基础 URL。请提供两者以刷新模型。" }, "getRequestyApiKey": "获取 Requesty API 密钥", "openRouterTransformsText": "自动压缩提示词和消息链到上下文长度限制内 (OpenRouter转换)", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 188d0fda88..b797c1ac82 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -123,7 +123,11 @@ "requestyApiKey": "Requesty API 金鑰", "refreshModels": { "label": "重新整理模型", - "hint": "請重新開啟設定以查看最新模型。" + "hint": "請重新開啟設定以查看最新模型。", + "loading": "正在重新整理模型...", + "success": "模型重新整理成功。", + "error": "重新整理模型失敗。請檢查您的設定並重試。", + "missingConfig": "缺少 API 金鑰或基礎 URL。請提供兩者以重新整理模型。" }, "getRequestyApiKey": "取得 Requesty API 金鑰", "openRouterTransformsText": "將提示和訊息鏈壓縮到上下文大小 (OpenRouter 轉換)", diff --git a/webview-ui/src/utils/__tests__/validate.test.ts b/webview-ui/src/utils/__tests__/validate.test.ts new file mode 100644 index 0000000000..8faa0743a7 --- /dev/null +++ b/webview-ui/src/utils/__tests__/validate.test.ts @@ -0,0 +1,73 @@ +// npx jest webview-ui/src/utils/__tests__/validate.test.ts + +import { validateModelId } from "../validate" +import { ProviderSettings, RouterModels } from "@roo/shared/api" + +// Mock i18next.t for error messages +jest.mock("i18next", () => ({ + t: (key: string, opts?: any) => { + if (key === "settings:validation.modelAvailability") { + return `Model ${opts.modelId} not available` + } + if (key === "settings:validation.modelId") { + return "Model ID required" + } + return key + }, +})) + +describe("validateModelId", () => { + const baseConfig: ProviderSettings = { + apiProvider: "litellm", + litellmModelId: "foo-model", + litellmApiKey: "key", + litellmBaseUrl: "http://localhost:4000", + } as any + + it("returns undefined if model is in the list", () => { + const routerModels: RouterModels = { + litellm: { "foo-model": { contextWindow: 1, supportsPromptCache: false } }, + openrouter: {}, + glama: {}, + unbound: {}, + requesty: {}, + } + expect(validateModelId(baseConfig, routerModels)).toBeUndefined() + }) + + it("returns error if model is not in the list", () => { + const routerModels: RouterModels = { + litellm: { "another-model": { contextWindow: 1, supportsPromptCache: false } }, + openrouter: {}, + glama: {}, + unbound: {}, + requesty: {}, + } + expect(validateModelId(baseConfig, routerModels)).toBe("Model foo-model not available") + }) + + it("returns undefined if routerModels is undefined", () => { + expect(validateModelId(baseConfig, undefined)).toBeUndefined() + }) + + it("returns error if modelId is missing", () => { + const config = { ...baseConfig, litellmModelId: undefined } + expect(validateModelId(config, undefined)).toBe("Model ID required") + }) + + it("returns error if model list is empty", () => { + const routerModels: RouterModels = { + litellm: {}, + openrouter: {}, + glama: {}, + unbound: {}, + requesty: {}, + } + expect(validateModelId(baseConfig, routerModels)).toBe("Model foo-model not available") + }) + + it("returns undefined for non-router providers", () => { + const config: ProviderSettings = { ...baseConfig, apiProvider: "openai" } + expect(validateModelId(config, undefined)).toBeUndefined() + }) +}) diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 0765fffeda..68f755485d 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -151,7 +151,7 @@ export function validateModelId(apiConfiguration: ProviderSettings, routerModels const models = routerModels?.[provider] - if (models && Object.keys(models).length > 1 && !Object.keys(models).includes(modelId)) { + if (models && !Object.keys(models).includes(modelId)) { return i18next.t("settings:validation.modelAvailability", { modelId }) }