Try to clean up some dynamic model stuff

This commit is contained in:
cte 2025-09-15 22:38:37 -07:00
parent f332589684
commit b7fcc39ea0
15 changed files with 224 additions and 275 deletions

View file

@ -80,6 +80,9 @@ export const providerNamesSchema = z.enum(providerNames)
export type ProviderName = z.infer<typeof providerNamesSchema>
export const isProviderName = (key: unknown): key is ProviderName =>
typeof key === "string" && providerNames.includes(key as ProviderName)
/**
* DynamicProvider
*
@ -87,14 +90,15 @@ export type ProviderName = z.infer<typeof providerNamesSchema>
*/
export const dynamicProviders = [
"glama",
"openrouter",
"vercel-ai-gateway",
"huggingface",
"litellm",
"openrouter",
"deepinfra",
"io-intelligence",
"requesty",
"unbound",
"deepinfra",
"vercel-ai-gateway",
"glama",
] as const satisfies readonly ProviderName[]
export type DynamicProvider = (typeof dynamicProviders)[number]
@ -102,6 +106,18 @@ export type DynamicProvider = (typeof dynamicProviders)[number]
export const isDynamicProvider = (key: string): key is DynamicProvider =>
dynamicProviders.includes(key as DynamicProvider)
/**
* LocalProvider
*
* Local providers require localhost API calls in order to get the model list.
*/
export const localProviders = ["ollama", "lmstudio"] as const satisfies readonly ProviderName[]
export type LocalProvider = (typeof localProviders)[number]
export const isLocalProvider = (key: string): key is LocalProvider => localProviders.includes(key as LocalProvider)
/**
* FauxProvider
*

View file

@ -1,17 +1,16 @@
import axios from "axios"
import { z } from "zod"
import type { ModelInfo } from "@roo-code/types"
import {
type ModelInfo,
HUGGINGFACE_API_URL,
HUGGINGFACE_CACHE_DURATION,
HUGGINGFACE_DEFAULT_MAX_TOKENS,
HUGGINGFACE_DEFAULT_CONTEXT_WINDOW,
} from "@roo-code/types"
import type { ModelRecord } from "../../../shared/api"
/**
* HuggingFace Provider Schema
*/
const huggingFaceProviderSchema = z.object({
provider: z.string(),
status: z.enum(["live", "staging", "error"]),
@ -27,7 +26,8 @@ const huggingFaceProviderSchema = z.object({
})
/**
* Represents a provider that can serve a HuggingFace model
* Represents a provider that can serve a HuggingFace model.
*
* @property provider - The provider identifier (e.g., "sambanova", "together")
* @property status - The current status of the provider
* @property supports_tools - Whether the provider supports tool/function calling
@ -37,9 +37,6 @@ const huggingFaceProviderSchema = z.object({
*/
export type HuggingFaceProvider = z.infer<typeof huggingFaceProviderSchema>
/**
* HuggingFace Model Schema
*/
const huggingFaceModelSchema = z.object({
id: z.string(),
object: z.literal("model"),
@ -50,6 +47,7 @@ const huggingFaceModelSchema = z.object({
/**
* Represents a HuggingFace model available through the router API
*
* @property id - The unique identifier of the model
* @property object - The object type (always "model")
* @property created - Unix timestamp of when the model was created
@ -58,26 +56,13 @@ const huggingFaceModelSchema = z.object({
*/
export type HuggingFaceModel = z.infer<typeof huggingFaceModelSchema>
/**
* HuggingFace API Response Schema
*/
const huggingFaceApiResponseSchema = z.object({
object: z.string(),
data: z.array(huggingFaceModelSchema),
})
/**
* Represents the response from the HuggingFace router API
* @property object - The response object type
* @property data - Array of available models
*/
type HuggingFaceApiResponse = z.infer<typeof huggingFaceApiResponseSchema>
/**
* Cache entry for storing fetched models
* @property data - The cached model records
* @property timestamp - Unix timestamp of when the cache was last updated
*/
interface CacheEntry {
data: ModelRecord
rawModels?: HuggingFaceModel[]
@ -87,13 +72,14 @@ interface CacheEntry {
let cache: CacheEntry | null = null
/**
* Parse a HuggingFace model into ModelInfo format
* Parse a HuggingFace model into ModelInfo format.
*
* @param model - The HuggingFace model to parse
* @param provider - Optional specific provider to use for capabilities
* @returns ModelInfo object compatible with the application's model system
*/
function parseHuggingFaceModel(model: HuggingFaceModel, provider?: HuggingFaceProvider): ModelInfo {
// Use provider-specific values if available, otherwise find first provider with values
// Use provider-specific values if available, otherwise find first provider with values.
const contextLength =
provider?.context_length ||
model.providers.find((p) => p.context_length)?.context_length ||
@ -101,13 +87,13 @@ function parseHuggingFaceModel(model: HuggingFaceModel, provider?: HuggingFacePr
const pricing = provider?.pricing || model.providers.find((p) => p.pricing)?.pricing
// Include provider name in description if specific provider is given
// Include provider name in description if specific provider is given.
const description = provider ? `${model.id} via ${provider.provider}` : `${model.id} via HuggingFace`
return {
maxTokens: Math.min(contextLength, HUGGINGFACE_DEFAULT_MAX_TOKENS),
contextWindow: contextLength,
supportsImages: false, // HuggingFace API doesn't provide this info yet
supportsImages: false, // HuggingFace API doesn't provide this info yet.
supportsPromptCache: false,
supportsComputerUse: false,
inputPrice: pricing?.input,
@ -125,7 +111,6 @@ function parseHuggingFaceModel(model: HuggingFaceModel, provider?: HuggingFacePr
export async function getHuggingFaceModels(): Promise<ModelRecord> {
const now = Date.now()
// Check cache
if (cache && now - cache.timestamp < HUGGINGFACE_CACHE_DURATION) {
return cache.data
}
@ -144,7 +129,7 @@ export async function getHuggingFaceModels(): Promise<ModelRecord> {
Pragma: "no-cache",
"Cache-Control": "no-cache",
},
timeout: 10000, // 10 second timeout
timeout: 10000,
})
const result = huggingFaceApiResponseSchema.safeParse(response.data)
@ -157,38 +142,31 @@ export async function getHuggingFaceModels(): Promise<ModelRecord> {
const validModels = result.data.data.filter((model) => model.providers.length > 0)
for (const model of validModels) {
// Add the base model
// Add the base model.
models[model.id] = parseHuggingFaceModel(model)
// Add provider-specific variants for all live providers
// Add provider-specific variants for all live providers.
for (const provider of model.providers) {
if (provider.status === "live") {
const providerKey = `${model.id}:${provider.provider}`
const providerModel = parseHuggingFaceModel(model, provider)
// Always add provider variants to show all available providers
// Always add provider variants to show all available providers.
models[providerKey] = providerModel
}
}
}
// Update cache
cache = {
data: models,
rawModels: validModels,
timestamp: now,
}
cache = { data: models, rawModels: validModels, timestamp: now }
return models
} catch (error) {
console.error("Error fetching HuggingFace models:", error)
// Return cached data if available
if (cache) {
return cache.data
}
// Re-throw with more context
if (axios.isAxiosError(error)) {
if (error.response) {
throw new Error(
@ -208,45 +186,35 @@ export async function getHuggingFaceModels(): Promise<ModelRecord> {
}
/**
* Get cached models without making an API request
* Get cached models without making an API request.
*/
export function getCachedHuggingFaceModels(): ModelRecord | null {
return cache?.data || null
}
/**
* Get cached raw models for UI display
* Get cached raw models for UI display.
*/
export function getCachedRawHuggingFaceModels(): HuggingFaceModel[] | null {
return cache?.rawModels || null
}
/**
* Clear the cache
*/
export function clearHuggingFaceCache(): void {
cache = null
}
/**
* HuggingFace Models Response Interface
*/
export interface HuggingFaceModelsResponse {
models: HuggingFaceModel[]
cached: boolean
timestamp: number
}
/**
* Get HuggingFace models with response metadata
* This function provides a higher-level API that includes cache status and timestamp
*/
export async function getHuggingFaceModelsWithMetadata(): Promise<HuggingFaceModelsResponse> {
try {
// First, trigger the fetch to populate cache
// First, trigger the fetch to populate cache.
await getHuggingFaceModels()
// Get the raw models from cache
// Get the raw models from cache.
const cachedRawModels = getCachedRawHuggingFaceModels()
if (cachedRawModels) {
@ -257,7 +225,7 @@ export async function getHuggingFaceModelsWithMetadata(): Promise<HuggingFaceMod
}
}
// If no cached raw models, fetch directly from API
// If no cached raw models, fetch directly from API.
const response = await axios.get(HUGGINGFACE_API_URL, {
headers: {
"Upgrade-Insecure-Requests": "1",
@ -281,10 +249,6 @@ export async function getHuggingFaceModelsWithMetadata(): Promise<HuggingFaceMod
}
} catch (error) {
console.error("Failed to get HuggingFace models:", error)
return {
models: [],
cached: false,
timestamp: Date.now(),
}
return { models: [], cached: false, timestamp: Date.now() }
}
}

View file

@ -1,12 +1,10 @@
import axios from "axios"
import { z } from "zod"
import type { ModelInfo } from "@roo-code/types"
import { IO_INTELLIGENCE_CACHE_DURATION } from "@roo-code/types"
import { type ModelInfo, IO_INTELLIGENCE_CACHE_DURATION } from "@roo-code/types"
import type { ModelRecord } from "../../../shared/api"
/**
* IO Intelligence Model Schema
*/
const ioIntelligenceModelSchema = z.object({
id: z.string(),
object: z.literal("model"),
@ -35,9 +33,6 @@ const ioIntelligenceModelSchema = z.object({
export type IOIntelligenceModel = z.infer<typeof ioIntelligenceModelSchema>
/**
* IO Intelligence API Response Schema
*/
const ioIntelligenceApiResponseSchema = z.object({
object: z.literal("list"),
data: z.array(ioIntelligenceModelSchema),
@ -45,9 +40,6 @@ const ioIntelligenceApiResponseSchema = z.object({
type IOIntelligenceApiResponse = z.infer<typeof ioIntelligenceApiResponseSchema>
/**
* Cache entry for storing fetched models
*/
interface CacheEntry {
data: ModelRecord
timestamp: number
@ -66,21 +58,15 @@ const MODEL_CONTEXT_LENGTHS: Record<string, number> = {
"openai/gpt-oss-120b": 131072,
}
/**
* Vision models that support images
*/
const VISION_MODELS = new Set([
"Qwen/Qwen2.5-VL-32B-Instruct",
"meta-llama/Llama-3.2-90B-Vision-Instruct",
"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8",
])
/**
* Parse an IO Intelligence model into ModelInfo format
*/
function parseIOIntelligenceModel(model: IOIntelligenceModel): ModelInfo {
const contextLength = MODEL_CONTEXT_LENGTHS[model.id] || 8192
// Cap maxTokens at 32k for very large context windows, or 20% of context length, whichever is smaller
// Cap maxTokens at 32k for very large context windows, or 20% of context length, whichever is smaller.
const maxTokens = Math.min(contextLength, Math.ceil(contextLength * 0.2), 32768)
const supportsImages = VISION_MODELS.has(model.id)
@ -101,7 +87,6 @@ function parseIOIntelligenceModel(model: IOIntelligenceModel): ModelInfo {
export async function getIOIntelligenceModels(apiKey?: string): Promise<ModelRecord> {
const now = Date.now()
// Check cache
if (cache && now - cache.timestamp < IO_INTELLIGENCE_CACHE_DURATION) {
return cache.data
}
@ -113,7 +98,6 @@ export async function getIOIntelligenceModels(apiKey?: string): Promise<ModelRec
"Content-Type": "application/json",
}
// Add authorization header if API key is provided
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`
} else {
@ -125,7 +109,7 @@ export async function getIOIntelligenceModels(apiKey?: string): Promise<ModelRec
"https://api.intelligence.io.solutions/api/v1/models",
{
headers,
timeout: 10000, // 10 second timeout
timeout: 10_000,
},
)
@ -140,22 +124,16 @@ export async function getIOIntelligenceModels(apiKey?: string): Promise<ModelRec
models[model.id] = parseIOIntelligenceModel(model)
}
// Update cache
cache = {
data: models,
timestamp: now,
}
cache = { data: models, timestamp: now }
return models
} catch (error) {
console.error("Error fetching IO Intelligence models:", error)
// Return cached data if available
if (cache) {
return cache.data
}
// Re-throw with more context
if (axios.isAxiosError(error)) {
if (error.response) {
throw new Error(
@ -174,16 +152,10 @@ export async function getIOIntelligenceModels(apiKey?: string): Promise<ModelRec
}
}
/**
* Get cached models without making an API request
*/
export function getCachedIOIntelligenceModels(): ModelRecord | null {
return cache?.data || null
}
/**
* Clear the cache
*/
export function clearIOIntelligenceCache(): void {
cache = null
}

View file

@ -1,27 +1,27 @@
import { ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types"
import { LLM, LLMInfo, LLMInstanceInfo, LMStudioClient } from "@lmstudio/sdk"
import axios from "axios"
import { LLM, LLMInfo, LLMInstanceInfo, LMStudioClient } from "@lmstudio/sdk"
import { type ModelInfo, lMStudioDefaultModelInfo } from "@roo-code/types"
import { flushModels, getModels } from "./modelCache"
const modelsWithLoadedDetails = new Set<string>()
export const hasLoadedFullDetails = (modelId: string): boolean => {
return modelsWithLoadedDetails.has(modelId)
}
export const hasLoadedFullDetails = (modelId: string): boolean => modelsWithLoadedDetails.has(modelId)
export const forceFullModelDetailsLoad = async (baseUrl: string, modelId: string): Promise<void> => {
try {
// test the connection to LM Studio first
// errors will be caught further down
// Test the connection to LM Studio first
// Crrors will be caught further down.
await axios.get(`${baseUrl}/v1/models`)
const lmsUrl = baseUrl.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://")
const client = new LMStudioClient({ baseUrl: lmsUrl })
await client.llm.model(modelId)
await flushModels("lmstudio")
await getModels({ provider: "lmstudio" }) // force cache update now
await getModels({ provider: "lmstudio" }) // Force cache update now.
// Mark this model as having full details loaded
// Mark this model as having full details loaded.
modelsWithLoadedDetails.add(modelId)
} catch (error) {
if (error.code === "ECONNREFUSED") {

View file

@ -2,11 +2,14 @@ import * as path from "path"
import fs from "fs/promises"
import NodeCache from "node-cache"
import type { ProviderName } from "@roo-code/types"
import { safeWriteJson } from "../../../utils/safeWriteJson"
import { ContextProxy } from "../../../core/config/ContextProxy"
import { getCacheDirectoryPath } from "../../../utils/storage"
import { RouterName, ModelRecord } from "../../../shared/api"
import type { RouterName, ModelRecord } from "../../../shared/api"
import { fileExistsAtPath } from "../../../utils/fs"
import { getOpenRouterModels } from "./openrouter"
@ -20,6 +23,8 @@ import { getOllamaModels } from "./ollama"
import { getLMStudioModels } from "./lmstudio"
import { getIOIntelligenceModels } from "./io-intelligence"
import { getDeepInfraModels } from "./deepinfra"
import { getHuggingFaceModels } from "./huggingface"
const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })
async function writeModels(router: RouterName, data: ModelRecord) {
@ -49,7 +54,9 @@ async function readModels(router: RouterName): Promise<ModelRecord | undefined>
*/
export const getModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
const { provider } = options
let models = getModelsFromCache(provider)
if (models) {
return models
}
@ -60,18 +67,18 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
models = await getOpenRouterModels()
break
case "requesty":
// Requesty models endpoint requires an API key for per-user custom policies
// Requesty models endpoint requires an API key for per-user custom policies.
models = await getRequestyModels(options.baseUrl, options.apiKey)
break
case "glama":
models = await getGlamaModels()
break
case "unbound":
// Unbound models endpoint requires an API key to fetch application specific models
// 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
// Type safety ensures apiKey and baseUrl are always provided for LiteLLM.
models = await getLiteLLMModels(options.apiKey, options.baseUrl)
break
case "ollama":
@ -89,22 +96,25 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
case "vercel-ai-gateway":
models = await getVercelAiGatewayModels()
break
case "huggingface":
models = await getHuggingFaceModels()
break
default: {
// Ensures router is exhaustively checked if RouterName is a strict union
// Ensures router is exhaustively checked if RouterName is a strict union.
const exhaustiveCheck: never = provider
throw new Error(`Unknown provider: ${exhaustiveCheck}`)
}
}
// Cache the fetched models (even if empty, to signify a successful fetch with no models)
// 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)
}
@ -118,13 +128,14 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
}
/**
* Flush models memory cache for a specific router
* 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)
}
export function getModelsFromCache(provider: string) {
export function getModelsFromCache(provider: ProviderName) {
return memoryCache.get<ModelRecord>(provider)
}

View file

@ -19,8 +19,10 @@ export class IOIntelligenceHandler extends BaseOpenAiCompatibleProvider<IOIntell
apiKey: options.ioIntelligenceApiKey,
})
}
override getModel() {
const modelId = this.options.ioIntelligenceModelId || (ioIntelligenceDefaultModelId as IOIntelligenceModelId)
const modelInfo =
this.providerModels[modelId as IOIntelligenceModelId] ?? this.providerModels[ioIntelligenceDefaultModelId]
@ -28,7 +30,7 @@ export class IOIntelligenceHandler extends BaseOpenAiCompatibleProvider<IOIntell
return { id: modelId as IOIntelligenceModelId, info: modelInfo }
}
// Return the requested model ID even if not found, with fallback info
// Return the requested model ID even if not found, with fallback info.
return {
id: modelId as IOIntelligenceModelId,
info: {

View file

@ -1,3 +1,5 @@
// npx vitest core/webview/__tests__/webviewMessageHandler.spec.ts
import type { Mock } from "vitest"
// Mock dependencies - must come before imports
@ -227,6 +229,8 @@ describe("webviewMessageHandler - requestRouterModels", () => {
apiKey: "litellm-key",
baseUrl: "http://localhost:4000",
})
// Note: huggingface is not fetched in requestRouterModels - it has its own handler
// Note: io-intelligence is not fetched because no API key is provided in the mock state
// Verify response was sent
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
@ -241,6 +245,8 @@ describe("webviewMessageHandler - requestRouterModels", () => {
ollama: {},
lmstudio: {},
"vercel-ai-gateway": mockModels,
huggingface: {},
"io-intelligence": {},
},
})
})
@ -330,6 +336,8 @@ describe("webviewMessageHandler - requestRouterModels", () => {
ollama: {},
lmstudio: {},
"vercel-ai-gateway": mockModels,
huggingface: {},
"io-intelligence": {},
},
})
})
@ -371,6 +379,8 @@ describe("webviewMessageHandler - requestRouterModels", () => {
ollama: {},
lmstudio: {},
"vercel-ai-gateway": mockModels,
huggingface: {},
"io-intelligence": {},
},
})

View file

@ -23,7 +23,7 @@ import { ClineProvider } from "./ClineProvider"
import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
import { changeLanguage, t } from "../../i18n"
import { Package } from "../../shared/package"
import { RouterName, toRouterName, ModelRecord } from "../../shared/api"
import { type RouterName, type ModelRecord, toRouterName } from "../../shared/api"
import { MessageEnhancer } from "./messageEnhancer"
import {
@ -756,15 +756,18 @@ export const webviewMessageHandler = async (
case "requestRouterModels":
const { apiConfiguration } = await provider.getState()
const routerModels: Partial<Record<RouterName, ModelRecord>> = {
const routerModels: Record<RouterName, ModelRecord> = {
openrouter: {},
requesty: {},
glama: {},
unbound: {},
"vercel-ai-gateway": {},
huggingface: {},
litellm: {},
deepinfra: {},
"io-intelligence": {},
requesty: {},
unbound: {},
glama: {},
ollama: {},
lmstudio: {},
deepinfra: {},
}
const safeGetModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
@ -775,11 +778,12 @@ export const webviewMessageHandler = async (
`Failed to fetch models in webviewMessageHandler requestRouterModels for ${options.provider}:`,
error,
)
throw error // Re-throw to be caught by Promise.allSettled
throw error // Re-throw to be caught by Promise.allSettled.
}
}
const modelFetchPromises: Array<{ key: RouterName; options: GetModelsOptions }> = [
const modelFetchPromises: { key: RouterName; options: GetModelsOptions }[] = [
{ key: "openrouter", options: { provider: "openrouter" } },
{
key: "requesty",
@ -802,8 +806,9 @@ export const webviewMessageHandler = async (
},
]
// Add IO Intelligence if API key is provided
// Add IO Intelligence if API key is provided.
const ioIntelligenceApiKey = apiConfiguration.ioIntelligenceApiKey
if (ioIntelligenceApiKey) {
modelFetchPromises.push({
key: "io-intelligence",
@ -811,11 +816,12 @@ export const webviewMessageHandler = async (
})
}
// Don't fetch Ollama and LM Studio models by default anymore
// They have their own specific handlers: requestOllamaModels and requestLmStudioModels
// Don't fetch Ollama and LM Studio models by default anymore.
// They have their own specific handlers: requestOllamaModels and requestLmStudioModels.
const litellmApiKey = apiConfiguration.litellmApiKey || message?.values?.litellmApiKey
const litellmBaseUrl = apiConfiguration.litellmBaseUrl || message?.values?.litellmBaseUrl
if (litellmApiKey && litellmBaseUrl) {
modelFetchPromises.push({
key: "litellm",
@ -826,24 +832,17 @@ export const webviewMessageHandler = async (
const results = await Promise.allSettled(
modelFetchPromises.map(async ({ key, options }) => {
const models = await safeGetModels(options)
return { key, models } // key is RouterName here
return { key, models } // The key is `ProviderName` here.
}),
)
const fetchedRouterModels: Partial<Record<RouterName, ModelRecord>> = {
...routerModels,
// Initialize ollama and lmstudio with empty objects since they use separate handlers
ollama: {},
lmstudio: {},
}
results.forEach((result, index) => {
const routerName = modelFetchPromises[index].key // Get RouterName using index
const routerName = modelFetchPromises[index].key
if (result.status === "fulfilled") {
fetchedRouterModels[routerName] = result.value.models
routerModels[routerName] = result.value.models
// Ollama and LM Studio settings pages still need these events
// Ollama and LM Studio settings pages still need these events.
if (routerName === "ollama" && Object.keys(result.value.models).length > 0) {
provider.postMessageToWebview({
type: "ollamaModels",
@ -856,11 +855,11 @@ export const webviewMessageHandler = async (
})
}
} else {
// Handle rejection: Post a specific error message for this provider
// Handle rejection: Post a specific error message for this provider.
const errorMessage = result.reason instanceof Error ? result.reason.message : String(result.reason)
console.error(`Error fetching models for ${routerName}:`, result.reason)
fetchedRouterModels[routerName] = {} // Ensure it's an empty object in the main routerModels message
routerModels[routerName] = {} // Ensure it's an empty object in the main routerModels message.
provider.postMessageToWebview({
type: "singleRouterModelFetchResponse",
@ -871,17 +870,13 @@ export const webviewMessageHandler = async (
}
})
provider.postMessageToWebview({
type: "routerModels",
routerModels: fetchedRouterModels as Record<RouterName, ModelRecord>,
})
provider.postMessageToWebview({ type: "routerModels", routerModels })
break
case "requestOllamaModels": {
// Specific handler for Ollama models only
// Specific handler for Ollama models only.
const { apiConfiguration: ollamaApiConfig } = await provider.getState()
try {
// Flush cache first to ensure fresh models
// Flush cache first to ensure fresh models.
await flushModels("ollama")
const ollamaModels = await getModels({
@ -891,10 +886,7 @@ export const webviewMessageHandler = async (
})
if (Object.keys(ollamaModels).length > 0) {
provider.postMessageToWebview({
type: "ollamaModels",
ollamaModels: ollamaModels,
})
provider.postMessageToWebview({ type: "ollamaModels", ollamaModels: ollamaModels })
}
} catch (error) {
// Silently fail - user hasn't configured Ollama yet
@ -903,10 +895,10 @@ export const webviewMessageHandler = async (
break
}
case "requestLmStudioModels": {
// Specific handler for LM Studio models only
// Specific handler for LM Studio models only.
const { apiConfiguration: lmStudioApiConfig } = await provider.getState()
try {
// Flush cache first to ensure fresh models
// Flush cache first to ensure fresh models.
await flushModels("lmstudio")
const lmStudioModels = await getModels({
@ -921,7 +913,7 @@ export const webviewMessageHandler = async (
})
}
} catch (error) {
// Silently fail - user hasn't configured LM Studio yet
// Silently fail - user hasn't configured LM Studio yet.
console.debug("LM Studio models fetch failed:", error)
}
break
@ -944,19 +936,18 @@ export const webviewMessageHandler = async (
provider.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels })
break
case "requestHuggingFaceModels":
// TODO: Why isn't this handled by `requestRouterModels` above?
try {
const { getHuggingFaceModelsWithMetadata } = await import("../../api/providers/fetchers/huggingface")
const huggingFaceModelsResponse = await getHuggingFaceModelsWithMetadata()
provider.postMessageToWebview({
type: "huggingFaceModels",
huggingFaceModels: huggingFaceModelsResponse.models,
})
} catch (error) {
console.error("Failed to fetch Hugging Face models:", error)
provider.postMessageToWebview({
type: "huggingFaceModels",
huggingFaceModels: [],
})
provider.postMessageToWebview({ type: "huggingFaceModels", huggingFaceModels: [] })
}
break
case "openImage":

View file

@ -1,8 +1,12 @@
import {
type ModelInfo,
type ProviderSettings,
type DynamicProvider,
type LocalProvider,
ANTHROPIC_DEFAULT_MAX_TOKENS,
CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS,
isDynamicProvider,
isLocalProvider,
} from "@roo-code/types"
// ApiHandlerOptions
@ -18,22 +22,9 @@ export type ApiHandlerOptions = Omit<ProviderSettings, "apiProvider"> & {
// RouterName
const routerNames = [
"openrouter",
"requesty",
"glama",
"unbound",
"litellm",
"ollama",
"lmstudio",
"io-intelligence",
"deepinfra",
"vercel-ai-gateway",
] as const
export type RouterName = DynamicProvider | LocalProvider
export type RouterName = (typeof routerNames)[number]
export const isRouterName = (value: string): value is RouterName => routerNames.includes(value as RouterName)
export const isRouterName = (value: string): value is RouterName => isDynamicProvider(value) || isLocalProvider(value)
export function toRouterName(value?: string): RouterName {
if (value && isRouterName(value)) {
@ -144,14 +135,32 @@ export const getModelMaxOutputTokens = ({
// GetModelsOptions
export type GetModelsOptions =
| { provider: "openrouter" }
| { provider: "glama" }
| { provider: "requesty"; apiKey?: string; baseUrl?: string }
| { provider: "unbound"; apiKey?: string }
| { provider: "litellm"; apiKey: string; baseUrl: string }
| { provider: "ollama"; baseUrl?: string; apiKey?: string }
| { provider: "lmstudio"; baseUrl?: string }
| { provider: "deepinfra"; apiKey?: string; baseUrl?: string }
| { provider: "io-intelligence"; apiKey: string }
| { provider: "vercel-ai-gateway" }
// Allow callers to always pass apiKey/baseUrl without excess property errors,
// while still enforcing required fields per provider where applicable.
type CommonFetchParams = {
apiKey?: string
baseUrl?: string
}
// Exhaustive, value-level map for all dynamic providers.
// If a new dynamic provider is added in packages/types, this will fail to compile
// until a corresponding entry is added here.
const dynamicProviderExtras = {
openrouter: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
"vercel-ai-gateway": {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
huggingface: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
litellm: {} as { apiKey: string; baseUrl: string },
deepinfra: {} as { apiKey?: string; baseUrl?: string },
"io-intelligence": {} as { apiKey: string },
requesty: {} as { apiKey?: string; baseUrl?: string },
unbound: {} as { apiKey?: string },
glama: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
ollama: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type
} as const satisfies Record<RouterName, object>
// Build the dynamic options union from the map, intersected with CommonFetchParams
// so extra fields are always allowed while required ones are enforced.
export type GetModelsOptions = {
[P in keyof typeof dynamicProviderExtras]: ({ provider: P } & (typeof dynamicProviderExtras)[P]) & CommonFetchParams
}[RouterName]

View file

@ -216,7 +216,7 @@ const ApiOptions = ({
values: {
baseUrl: apiConfiguration?.openAiBaseUrl,
apiKey: apiConfiguration?.openAiApiKey,
customHeaders: {}, // Reserved for any additional headers
customHeaders: {}, // Reserved for any additional headers.
openAiHeaders: headerObject,
},
})
@ -226,9 +226,7 @@ const ApiOptions = ({
vscode.postMessage({ type: "requestLmStudioModels" })
} else if (selectedProvider === "vscode-lm") {
vscode.postMessage({ type: "requestVsCodeLmModels" })
} else if (selectedProvider === "litellm") {
vscode.postMessage({ type: "requestRouterModels" })
} else if (selectedProvider === "deepinfra") {
} else if (selectedProvider === "litellm" || selectedProvider === "deepinfra") {
vscode.postMessage({ type: "requestRouterModels" })
}
},

View file

@ -60,19 +60,19 @@ export const HuggingFace = ({ apiConfiguration, setApiConfigurationField }: Hugg
[setApiConfigurationField],
)
// Fetch models when component mounts
// Fetch models when component mounts.
useEffect(() => {
setLoading(true)
vscode.postMessage({ type: "requestHuggingFaceModels" })
}, [])
// Handle messages from extension
// Handle messages from extension.
const onMessage = useCallback((event: MessageEvent) => {
const message: ExtensionMessage = event.data
switch (message.type) {
case "huggingFaceModels":
setModels(message.huggingFaceModels || [])
setModels(message.huggingFaceModels?.sort((a, b) => a.id.localeCompare(b.id)) || [])
setLoading(false)
break
}

View file

@ -85,6 +85,7 @@ export const LiteLLM = ({
setRefreshError(t("settings:providers.refreshModels.missingConfig"))
return
}
vscode.postMessage({ type: "requestRouterModels", values: { litellmApiKey: key, litellmBaseUrl: url } })
}, [apiConfiguration, setRefreshStatus, setRefreshError, t])

View file

@ -90,11 +90,13 @@ export const Unbound = ({
const modelsPromise = new Promise<void>((resolve) => {
const messageHandler = (event: MessageEvent) => {
const message = event.data
if (message.type === "routerModels") {
window.removeEventListener("message", messageHandler)
resolve()
}
}
window.addEventListener("message", messageHandler)
})

View file

@ -42,6 +42,7 @@ describe("Model Validation Functions", () => {
deepinfra: {},
"io-intelligence": {},
"vercel-ai-gateway": {},
huggingface: {},
}
const allowAllOrganization: OrganizationAllowList = {

View file

@ -1,8 +1,17 @@
import i18next from "i18next"
import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types"
import {
type ProviderSettings,
type OrganizationAllowList,
type ProviderName,
modelIdKeysByProvider,
isProviderName,
isDynamicProvider,
isFauxProvider,
isCustomProvider,
} from "@roo-code/types"
import { isRouterName, RouterModels } from "@roo/api"
import type { RouterModels } from "@roo/api"
export function validateApiConfiguration(
apiConfiguration: ProviderSettings,
@ -10,6 +19,7 @@ export function validateApiConfiguration(
organizationAllowList?: OrganizationAllowList,
): string | undefined {
const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration)
if (keysAndIdsPresentErrorMessage) {
return keysAndIdsPresentErrorMessage
}
@ -18,11 +28,12 @@ export function validateApiConfiguration(
apiConfiguration,
organizationAllowList,
)
if (organizationAllowListError) {
return organizationAllowListError.message
}
return validateModelId(apiConfiguration, routerModels)
return validateDynamicProviderModelId(apiConfiguration, routerModels)
}
function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): string | undefined {
@ -161,9 +172,13 @@ function validateProviderAgainstOrganizationSettings(
): ValidationError | undefined {
if (organizationAllowList && !organizationAllowList.allowAll) {
const provider = apiConfiguration.apiProvider
if (!provider) return undefined
if (!provider) {
return undefined
}
const providerConfig = organizationAllowList.providers[provider]
if (!providerConfig) {
return {
message: i18next.t("settings:validation.providerNotAllowed", { provider }),
@ -188,47 +203,28 @@ function validateProviderAgainstOrganizationSettings(
}
}
function getModelIdForProvider(apiConfiguration: ProviderSettings, provider: string): string | undefined {
switch (provider) {
case "openrouter":
return apiConfiguration.openRouterModelId
case "glama":
return apiConfiguration.glamaModelId
case "unbound":
return apiConfiguration.unboundModelId
case "requesty":
return apiConfiguration.requestyModelId
case "deepinfra":
return apiConfiguration.deepInfraModelId
case "litellm":
return apiConfiguration.litellmModelId
case "openai":
return apiConfiguration.openAiModelId
case "ollama":
return apiConfiguration.ollamaModelId
case "lmstudio":
return apiConfiguration.lmStudioModelId
case "vscode-lm":
// vsCodeLmModelSelector is an object, not a string
return apiConfiguration.vsCodeLmModelSelector?.id
case "huggingface":
return apiConfiguration.huggingFaceModelId
case "io-intelligence":
return apiConfiguration.ioIntelligenceModelId
case "vercel-ai-gateway":
return apiConfiguration.vercelAiGatewayModelId
default:
return apiConfiguration.apiModelId
function getModelIdForProvider(apiConfiguration: ProviderSettings, provider: ProviderName): string | undefined {
if (provider === "vscode-lm") {
return apiConfiguration.vsCodeLmModelSelector?.id
}
if (isCustomProvider(provider) || isFauxProvider(provider)) {
return apiConfiguration.apiModelId
}
return apiConfiguration[modelIdKeysByProvider[provider]]
}
/**
* Validates an Amazon Bedrock ARN format and optionally checks if the region in the ARN matches the provided region
* Validates an Amazon Bedrock ARN format and optionally checks if the region in
* the ARN matches the provided region.
*
* @param arn The ARN string to validate
* @param region Optional region to check against the ARN's region
* @returns An object with validation results: { isValid, arnRegion, errorMessage }
*/
export function validateBedrockArn(arn: string, region?: string) {
// Validate ARN format
// Validate ARN format.
const arnRegex = /^arn:aws:(?:bedrock|sagemaker):([^:]+):([^:]*):(?:([^/]+)\/([\w.\-:]+)|([^/]+))$/
const match = arn.match(arnRegex)
@ -240,10 +236,10 @@ export function validateBedrockArn(arn: string, region?: string) {
}
}
// Extract region from ARN
// Extract region from ARN.
const arnRegion = match[1]
// Check if region in ARN matches provided region (if specified)
// Check if region in ARN matches provided region (if specified).
if (region && arnRegion !== region) {
return {
isValid: true,
@ -252,51 +248,22 @@ export function validateBedrockArn(arn: string, region?: string) {
}
}
// ARN is valid and region matches (or no region was provided to check against)
// ARN is valid and region matches (or no region was provided to check against).
return { isValid: true, arnRegion, errorMessage: undefined }
}
export function validateModelId(apiConfiguration: ProviderSettings, routerModels?: RouterModels): string | undefined {
function validateDynamicProviderModelId(
apiConfiguration: ProviderSettings,
routerModels?: RouterModels,
): string | undefined {
const provider = apiConfiguration.apiProvider ?? ""
if (!isRouterName(provider)) {
// We only validate model ids from dynamic providers.
if (!isDynamicProvider(provider)) {
return undefined
}
let modelId: string | undefined
switch (provider) {
case "openrouter":
modelId = apiConfiguration.openRouterModelId
break
case "glama":
modelId = apiConfiguration.glamaModelId
break
case "unbound":
modelId = apiConfiguration.unboundModelId
break
case "requesty":
modelId = apiConfiguration.requestyModelId
break
case "deepinfra":
modelId = apiConfiguration.deepInfraModelId
break
case "ollama":
modelId = apiConfiguration.ollamaModelId
break
case "lmstudio":
modelId = apiConfiguration.lmStudioModelId
break
case "litellm":
modelId = apiConfiguration.litellmModelId
break
case "io-intelligence":
modelId = apiConfiguration.ioIntelligenceModelId
break
case "vercel-ai-gateway":
modelId = apiConfiguration.vercelAiGatewayModelId
break
}
const modelId = getModelIdForProvider(apiConfiguration, provider)
if (!modelId) {
return i18next.t("settings:validation.modelId")
@ -312,39 +279,44 @@ export function validateModelId(apiConfiguration: ProviderSettings, routerModels
}
/**
* Extracts model-specific validation errors from the API configuration
* This is used to show model errors specifically in the model selector components
* Extracts model-specific validation errors from the API configuration.
* This is used to show model errors specifically in the model selector components.
*/
export function getModelValidationError(
apiConfiguration: ProviderSettings,
routerModels?: RouterModels,
organizationAllowList?: OrganizationAllowList,
): string | undefined {
const modelId = getModelIdForProvider(apiConfiguration, apiConfiguration.apiProvider || "")
const modelId = isProviderName(apiConfiguration.apiProvider)
? getModelIdForProvider(apiConfiguration, apiConfiguration.apiProvider)
: apiConfiguration.apiModelId
const configWithModelId = {
...apiConfiguration,
apiModelId: modelId || "",
}
const orgError = validateProviderAgainstOrganizationSettings(configWithModelId, organizationAllowList)
if (orgError && orgError.code === "MODEL_NOT_ALLOWED") {
return orgError.message
}
return validateModelId(configWithModelId, routerModels)
return validateDynamicProviderModelId(configWithModelId, routerModels)
}
/**
* Validates API configuration but excludes model-specific errors
* Validates API configuration but excludes model-specific errors.
* This is used for the general API error display to prevent duplication
* when model errors are shown in the model selector
* when model errors are shown in the model selector.
*/
export function validateApiConfigurationExcludingModelErrors(
apiConfiguration: ProviderSettings,
_routerModels?: RouterModels, // keeping this for compatibility with the old function
_routerModels?: RouterModels, // Keeping this for compatibility with the old function.
organizationAllowList?: OrganizationAllowList,
): string | undefined {
const keysAndIdsPresentErrorMessage = validateModelsAndKeysProvided(apiConfiguration)
if (keysAndIdsPresentErrorMessage) {
return keysAndIdsPresentErrorMessage
}
@ -354,11 +326,11 @@ export function validateApiConfigurationExcludingModelErrors(
organizationAllowList,
)
// only return organization errors if they're not model-specific
// Inly return organization errors if they're not model-specific.
if (organizationAllowListError && organizationAllowListError.code === "PROVIDER_NOT_ALLOWED") {
return organizationAllowListError.message
}
// skip model validation errors as they'll be shown in the model selector
// Skip model validation errors as they'll be shown in the model selector.
return undefined
}