Implement LiteLLM provider configuration

This commit is contained in:
slytechnical 2025-05-21 01:20:07 -05:00
parent ea93cea9ac
commit 4591b1b4f7
42 changed files with 1548 additions and 267 deletions

View file

@ -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",
)
})
})
})

View file

@ -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<ModelRecord> {
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."}`)
}
}
}

View file

@ -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<ModelRecord | undefined>
* 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<ModelRecord> => {
let models = memoryCache.get<ModelRecord>(router)
export const getModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
const { provider } = options
let models = memoryCache.get<ModelRecord>(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)
}

View file

@ -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,

View file

@ -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 {

View file

@ -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,

View file

@ -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()
}

View file

@ -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 {

View file

@ -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" })
})
})
})

View file

@ -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<Record<RouterName, ModelRecord>> = {
openrouter: {},
requesty: {},
glama: {},
unbound: {},
litellm: {},
}
const safeGetModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
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<RouterName, ModelRecord>,
})
break
case "requestOpenAiModels":

View file

@ -300,22 +300,23 @@ export class DiffViewProvider {
private async closeAllDiffViews(): Promise<void> {
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<vscode.TextEditor> {
@ -422,7 +423,7 @@ export class DiffViewProvider {
return result
}
async reset() : Promise<void> {
async reset(): Promise<void> {
await this.closeAllDiffViews()
this.editType = undefined
this.isEditing = false

View file

@ -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
}

View file

@ -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<typeof checkoutRestorePayloadSchema>
export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload
export type WebViewMessagePayload = CheckpointDiffPayload | CheckpointRestorePayload | GetModelsOptions

View file

@ -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<string, ModelInfo>
export type RouterModels = Record<RouterName, ModelRecord>
/**
* 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 }

View file

@ -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<React.SetStateAction<string | undefined>>
}
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<RouterModels>(
initialRouterModels || defaultRouterModels,
)
useEffect(() => {
if (initialRouterModels) {
setCurrentRouterModels(initialRouterModels)
} else {
setCurrentRouterModels(defaultRouterModels)
}
}, [initialRouterModels, defaultRouterModels])
// Listen for specific provider model updates using useEvent
useEvent("message", (event: MessageEvent<ExtensionMessage>) => {
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 = ({
<OpenRouter
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
routerModels={currentRouterModels}
selectedModelId={selectedModelId}
uriScheme={uriScheme}
fromWelcomeView={fromWelcomeView}
@ -305,8 +360,7 @@ const ApiOptions = ({
<Requesty
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
refetchRouterModels={refetchRouterModels}
routerModels={currentRouterModels}
/>
)}
@ -314,7 +368,7 @@ const ApiOptions = ({
<Glama
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
routerModels={currentRouterModels}
uriScheme={uriScheme}
/>
)}
@ -323,7 +377,7 @@ const ApiOptions = ({
<Unbound
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
routerModels={currentRouterModels}
/>
)}
@ -394,7 +448,7 @@ const ApiOptions = ({
<LiteLLM
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
routerModels={currentRouterModels}
/>
)}

View file

@ -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<SettingsViewRef, SettingsViewProps>(({ 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) {

View file

@ -40,8 +40,8 @@ jest.mock("vscrui", () => ({
// Mock @shadcn/ui components
jest.mock("@/components/ui", () => ({
Select: ({ children, value, onValueChange }: any) => (
<div className="select-mock">
Select: ({ children, value, onValueChange, ...rest }: any) => (
<div className="select-mock" data-testid={rest["data-testid"]}>
<select value={value} onChange={(e) => onValueChange && onValueChange(e.target.value)}>
{children}
</select>
@ -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 },
}
}

View file

@ -134,6 +134,14 @@ jest.mock("@/components/ui", () => ({
),
}))
// Mock ApiOptions to inspect its props
jest.mock("../ApiOptions", () => ({
__esModule: true,
default: jest.fn((props) => (
<div data-testid="api-options-mock" data-apiconfiguration={JSON.stringify(props.apiConfiguration)} />
)),
}))
// 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", () => {

View file

@ -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<string | undefined>()
const initialRefreshPerformedRef = useRef(false)
const handleInputChange = useCallback(
<K extends keyof ProviderSettings, E>(
@ -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<ExtensionMessage>) => {
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 (
<>
<VSCodeTextField
value={apiConfiguration?.litellmBaseUrl || "http://localhost:4000"}
value={apiConfiguration?.litellmBaseUrl || ""}
onInput={handleInputChange("litellmBaseUrl")}
placeholder="http://localhost:4000"
className="w-full">
@ -51,6 +130,36 @@ export const LiteLLM = ({ apiConfiguration, setApiConfigurationField, routerMode
{t("settings:providers.apiKeyStorageNotice")}
</div>
<Button
variant="outline"
onClick={handleRefreshModels}
disabled={
refreshStatus === "loading" || !apiConfiguration.litellmApiKey || !apiConfiguration.litellmBaseUrl
}
className="w-full">
<div className="flex items-center gap-2">
{refreshStatus === "loading" ? (
<span className="codicon codicon-loading codicon-modifier-spin" />
) : (
<span className="codicon codicon-refresh" />
)}
{t("settings:providers.refreshModels.label")}
</div>
</Button>
{refreshStatus === "loading" && (
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.refreshModels.loading")}
</div>
)}
{refreshStatus === "success" && (
<div className="text-sm text-vscode-foreground">{t("settings:providers.refreshModels.success")}</div>
)}
{refreshStatus === "error" && (
<div className="text-sm text-vscode-errorForeground">
{refreshError || t("settings:providers.refreshModels.error")}
</div>
)}
<ModelPicker
apiConfiguration={apiConfiguration}
defaultModelId={litellmDefaultModelId}

View file

@ -1,8 +1,8 @@
import { useCallback, useState } from "react"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { useEvent } from "react-use"
import { ProviderSettings, RouterModels, requestyDefaultModelId } from "@roo/shared/api"
import { vscode } from "@src/utils/vscode"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
@ -11,23 +11,19 @@ import { Button } from "@src/components/ui"
import { inputEventTransform } from "../transforms"
import { ModelPicker } from "../ModelPicker"
import { RequestyBalanceDisplay } from "./RequestyBalanceDisplay"
import { WebviewMessage } from "@roo/shared/WebviewMessage"
import { ExtensionMessage, ProviderModelsResponsePayload } from "@roo/shared/ExtensionMessage"
type RequestyProps = {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => 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<boolean>()
const [refreshStatus, setRefreshStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
const [refreshError, setRefreshError] = useState<string | undefined>()
const handleInputChange = useCallback(
<K extends keyof ProviderSettings, E>(
@ -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<ExtensionMessage>) => {
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 (
<>
<VSCodeTextField
@ -68,19 +90,29 @@ export const Requesty = ({
)}
<Button
variant="outline"
onClick={() => {
vscode.postMessage({ type: "flushRouterModels", text: "requesty" })
refetchRouterModels()
setDidRefetch(true)
}}>
onClick={handleRefreshModels}
disabled={refreshStatus === "loading"}
className="w-full">
<div className="flex items-center gap-2">
<span className="codicon codicon-refresh" />
{refreshStatus === "loading" ? (
<span className="codicon codicon-loading codicon-modifier-spin" />
) : (
<span className="codicon codicon-refresh" />
)}
{t("settings:providers.refreshModels.label")}
</div>
</Button>
{didRefetch && (
<div className="flex items-center text-vscode-errorForeground">
{t("settings:providers.refreshModels.hint")}
{refreshStatus === "loading" && (
<div className="text-sm text-vscode-descriptionForeground">
{t("settings:providers.refreshModels.loading")}
</div>
)}
{refreshStatus === "success" && (
<div className="text-sm text-vscode-foreground">{t("settings:providers.refreshModels.success")}</div>
)}
{refreshStatus === "error" && (
<div className="text-sm text-vscode-errorForeground">
{refreshError || t("settings:providers.refreshModels.error")}
</div>
)}
<ModelPicker

View file

@ -0,0 +1,178 @@
import React from "react"
import { render, screen } from "@testing-library/react"
import { I18nextProvider } from "react-i18next"
import i18next from "i18next"
import { LiteLLM, LiteLLMProps } from "../LiteLLM"
import { vscode } from "@/utils/vscode"
// Minimal i18n instance for testing
const testI18n = i18next.createInstance()
testI18n.init({
fallbackLng: "en",
debug: false,
resources: {
en: {
translation: {
"settings:providers.refreshModels.label": "Refresh Models",
"settings:providers.refreshModels.missingConfig": "API key or base URL missing.",
},
},
},
interpolation: {
escapeValue: false, // Not needed for React
},
})
// Mock vscode API
jest.mock("@/utils/vscode", () => ({
vscode: {
postMessage: jest.fn(),
},
}))
// Mock VSCodeTextField
jest.mock("@vscode/webview-ui-toolkit/react", () => ({
VSCodeTextField: ({ children, value, onInput, type }: any) => (
<div>
{children}
<input
type={type || "text"}
value={value}
onChange={(e) => onInput && onInput({ target: { value: e.target.value } })}
/>
</div>
),
}))
// Mock ModelPicker
jest.mock("../../ModelPicker", () => ({
ModelPicker: () => <div data-testid="model-picker-mock">ModelPicker</div>,
}))
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<LiteLLMProps>) => {
return render(
<I18nextProvider i18n={testI18n}>
<LiteLLM {...defaultProps} {...props} />
</I18nextProvider>,
)
}
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(
<I18nextProvider i18n={testI18n}>
<LiteLLM
{...defaultProps}
apiConfiguration={{
...defaultProps.apiConfiguration,
litellmApiKey: "test-key", // Same key
litellmBaseUrl: "http://localhost:4000", // Same URL
}}
routerModels={{
litellm: { "new-model": { contextWindow: 4096, supportsPromptCache: false } },
glama: {},
openrouter: {},
unbound: {},
requesty: {},
}}
/>
</I18nextProvider>,
)
// 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()
})
})

View file

@ -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<string | undefined>(undefined)
// Memoize the setApiConfigurationField function to pass to ApiOptions
const setApiConfigurationFieldForApiOptions = useCallback(
<K extends keyof ProviderSettings>(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<ProviderSettings> = {}
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}
/>

View file

@ -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) =>

View file

@ -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 (<a>Transformacions d'OpenRouter</a>)",

View file

@ -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 (<a>OpenRouter Transformationen</a>)",

View file

@ -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 (<a>OpenRouter Transforms</a>)",

View file

@ -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 (<a>Transformaciones de OpenRouter</a>)",

View file

@ -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 (<a>Transformations OpenRouter</a>)",

View file

@ -123,7 +123,11 @@
"requestyApiKey": "Requesty API कुंजी",
"refreshModels": {
"label": "मॉडल रिफ्रेश करें",
"hint": "नवीनतम मॉडल देखने के लिए कृपया सेटिंग्स को फिर से खोलें।"
"hint": "नवीनतम मॉडल देखने के लिए कृपया सेटिंग्स को फिर से खोलें।",
"loading": "मॉडल रिफ्रेश हो रहे हैं...",
"success": "मॉडल सफलतापूर्वक रिफ्रेश हो गए।",
"error": "मॉडल रिफ्रेश करने में विफल। कृपया अपनी कॉन्फ़िगरेशन जांचें और पुनः प्रयास करें।",
"missingConfig": "API कुंजी या बेस URL अनुपलब्ध है। मॉडल रिफ्रेश करने के लिए कृपया दोनों प्रदान करें।"
},
"getRequestyApiKey": "Requesty API कुंजी प्राप्त करें",
"openRouterTransformsText": "संदर्भ आकार के लिए प्रॉम्प्ट और संदेश श्रृंखलाओं को संपीड़ित करें (<a>OpenRouter ट्रांसफॉर्म</a>)",

View file

@ -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 (<a>Trasformazioni OpenRouter</a>)",

View file

@ -123,7 +123,11 @@
"requestyApiKey": "Requesty APIキー",
"refreshModels": {
"label": "モデルを更新",
"hint": "最新のモデルを表示するには設定を再度開いてください。"
"hint": "最新のモデルを表示するには設定を再度開いてください。",
"loading": "モデルを更新中...",
"success": "モデルが正常に更新されました。",
"error": "モデルの更新に失敗しました。設定を確認して再試行してください。",
"missingConfig": "APIキーまたはベースURLが不足しています。モデルを更新するには両方を提供してください。"
},
"getRequestyApiKey": "Requesty APIキーを取得",
"openRouterTransformsText": "プロンプトとメッセージチェーンをコンテキストサイズに圧縮 (<a>OpenRouter Transforms</a>)",

View file

@ -123,7 +123,11 @@
"requestyApiKey": "Requesty API 키",
"refreshModels": {
"label": "모델 새로고침",
"hint": "최신 모델을 보려면 설정을 다시 열어주세요."
"hint": "최신 모델을 보려면 설정을 다시 열어주세요.",
"loading": "모델 새로고침 중...",
"success": "모델이 성공적으로 새로고침되었습니다.",
"error": "모델 새로고침에 실패했습니다. 설정을 확인하고 다시 시도해주세요.",
"missingConfig": "API 키 또는 기본 URL이 누락되었습니다. 모델을 새로고침하려면 둘 다 제공해주세요."
},
"getRequestyApiKey": "Requesty API 키 받기",
"openRouterTransformsText": "프롬프트와 메시지 체인을 컨텍스트 크기로 압축 (<a>OpenRouter Transforms</a>)",

View file

@ -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 (<a>OpenRouter Transforms</a>)",

View file

@ -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 (<a>Transformacje OpenRouter</a>)",

View file

@ -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 (<a>Transformações OpenRouter</a>)",

View file

@ -123,7 +123,11 @@
"requestyApiKey": "Requesty API-ключ",
"refreshModels": {
"label": "Обновить модели",
"hint": "Пожалуйста, откройте настройки заново, чтобы увидеть последние модели."
"hint": "Пожалуйста, откройте настройки заново, чтобы увидеть последние модели.",
"loading": "Обновление моделей...",
"success": "Модели успешно обновлены.",
"error": "Не удалось обновить модели. Пожалуйста, проверьте вашу конфигурацию и попробуйте снова.",
"missingConfig": "Отсутствует API-ключ или базовый URL. Пожалуйста, укажите оба параметра для обновления моделей."
},
"getRequestyApiKey": "Получить Requesty API-ключ",
"openRouterTransformsText": "Сжимать подсказки и цепочки сообщений до размера контекста (<a>OpenRouter Transforms</a>)",

View file

@ -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 (<a>OpenRouter Dönüşümleri</a>)",

View file

@ -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 (<a>OpenRouter Transforms</a>)",

View file

@ -123,7 +123,11 @@
"requestyApiKey": "Requesty API 密钥",
"refreshModels": {
"label": "刷新模型",
"hint": "请重新打开设置以查看最新模型。"
"hint": "请重新打开设置以查看最新模型。",
"loading": "正在刷新模型...",
"success": "模型刷新成功。",
"error": "刷新模型失败。请检查您的配置并重试。",
"missingConfig": "缺少 API 密钥或基础 URL。请提供两者以刷新模型。"
},
"getRequestyApiKey": "获取 Requesty API 密钥",
"openRouterTransformsText": "自动压缩提示词和消息链到上下文长度限制内 (<a>OpenRouter转换</a>)",

View file

@ -123,7 +123,11 @@
"requestyApiKey": "Requesty API 金鑰",
"refreshModels": {
"label": "重新整理模型",
"hint": "請重新開啟設定以查看最新模型。"
"hint": "請重新開啟設定以查看最新模型。",
"loading": "正在重新整理模型...",
"success": "模型重新整理成功。",
"error": "重新整理模型失敗。請檢查您的設定並重試。",
"missingConfig": "缺少 API 金鑰或基礎 URL。請提供兩者以重新整理模型。"
},
"getRequestyApiKey": "取得 Requesty API 金鑰",
"openRouterTransformsText": "將提示和訊息鏈壓縮到上下文大小 (<a>OpenRouter 轉換</a>)",

View file

@ -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()
})
})

View file

@ -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 })
}