mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-12 23:01:21 +00:00
Implement LiteLLM provider configuration
This commit is contained in:
parent
ea93cea9ac
commit
4591b1b4f7
42 changed files with 1548 additions and 267 deletions
249
src/api/providers/__tests__/litellm.test.ts
Normal file
249
src/api/providers/__tests__/litellm.test.ts
Normal 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",
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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."}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
176
src/core/webview/__tests__/webviewMessageHandler.test.ts
Normal file
176
src/core/webview/__tests__/webviewMessageHandler.test.ts
Normal 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" })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -123,7 +123,11 @@
|
|||
"requestyApiKey": "Requesty API कुंजी",
|
||||
"refreshModels": {
|
||||
"label": "मॉडल रिफ्रेश करें",
|
||||
"hint": "नवीनतम मॉडल देखने के लिए कृपया सेटिंग्स को फिर से खोलें।"
|
||||
"hint": "नवीनतम मॉडल देखने के लिए कृपया सेटिंग्स को फिर से खोलें।",
|
||||
"loading": "मॉडल रिफ्रेश हो रहे हैं...",
|
||||
"success": "मॉडल सफलतापूर्वक रिफ्रेश हो गए।",
|
||||
"error": "मॉडल रिफ्रेश करने में विफल। कृपया अपनी कॉन्फ़िगरेशन जांचें और पुनः प्रयास करें।",
|
||||
"missingConfig": "API कुंजी या बेस URL अनुपलब्ध है। मॉडल रिफ्रेश करने के लिए कृपया दोनों प्रदान करें।"
|
||||
},
|
||||
"getRequestyApiKey": "Requesty API कुंजी प्राप्त करें",
|
||||
"openRouterTransformsText": "संदर्भ आकार के लिए प्रॉम्प्ट और संदेश श्रृंखलाओं को संपीड़ित करें (<a>OpenRouter ट्रांसफॉर्म</a>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -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>)",
|
||||
|
|
|
|||
|
|
@ -123,7 +123,11 @@
|
|||
"requestyApiKey": "Requesty API 密钥",
|
||||
"refreshModels": {
|
||||
"label": "刷新模型",
|
||||
"hint": "请重新打开设置以查看最新模型。"
|
||||
"hint": "请重新打开设置以查看最新模型。",
|
||||
"loading": "正在刷新模型...",
|
||||
"success": "模型刷新成功。",
|
||||
"error": "刷新模型失败。请检查您的配置并重试。",
|
||||
"missingConfig": "缺少 API 密钥或基础 URL。请提供两者以刷新模型。"
|
||||
},
|
||||
"getRequestyApiKey": "获取 Requesty API 密钥",
|
||||
"openRouterTransformsText": "自动压缩提示词和消息链到上下文长度限制内 (<a>OpenRouter转换</a>)",
|
||||
|
|
|
|||
|
|
@ -123,7 +123,11 @@
|
|||
"requestyApiKey": "Requesty API 金鑰",
|
||||
"refreshModels": {
|
||||
"label": "重新整理模型",
|
||||
"hint": "請重新開啟設定以查看最新模型。"
|
||||
"hint": "請重新開啟設定以查看最新模型。",
|
||||
"loading": "正在重新整理模型...",
|
||||
"success": "模型重新整理成功。",
|
||||
"error": "重新整理模型失敗。請檢查您的設定並重試。",
|
||||
"missingConfig": "缺少 API 金鑰或基礎 URL。請提供兩者以重新整理模型。"
|
||||
},
|
||||
"getRequestyApiKey": "取得 Requesty API 金鑰",
|
||||
"openRouterTransformsText": "將提示和訊息鏈壓縮到上下文大小 (<a>OpenRouter 轉換</a>)",
|
||||
|
|
|
|||
73
webview-ui/src/utils/__tests__/validate.test.ts
Normal file
73
webview-ui/src/utils/__tests__/validate.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue