Skip to content
1 change: 1 addition & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const dynamicProviders = [
"requesty",
"unbound",
"glama",
"roo",
] as const

export type DynamicProvider = (typeof dynamicProviders)[number]
Expand Down
37 changes: 37 additions & 0 deletions pr-body.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## Summary

Enables the Roo Code extension to dynamically load available models from the Roo Code Cloud provider via the `/v1/models` endpoint.

## Changes

- **New fetcher**: Added `getRooModels()` function to fetch models from Roo Code Cloud `/v1/models` endpoint
- **Dynamic provider**: Added "roo" to the list of dynamic providers
- **Type updates**: Updated RooHandler to support dynamic model IDs (changed from `RooModelId` to `string`)
- **Model caching**: Integrated with existing modelCache infrastructure for efficient caching
- **Graceful fallback**: Falls back to static model definitions if dynamic loading fails

## Technical Details

### Model Loading Strategy

- Models are loaded asynchronously on handler initialization
- Dynamic models are merged with static models (static definitions take precedence)
- Uses 5-minute memory cache + file cache from existing infrastructure
- 10-second timeout prevents hanging on network issues

### Type Safety

- Maintains backward compatibility with existing static models
- Generic type changed from `RooModelId` to `string` to support dynamic model IDs
- All type definitions updated across shared/api.ts and provider-settings.ts

## Testing

- Linting passes
- Type checks pass
- Follows patterns from other dynamic providers (requesty, glama, unbound)
- Error handling with descriptive logging

## Related

This PR works in conjunction with Roo-Code-Cloud PR #1316 which adds the `/v1/models` endpoint.
8 changes: 8 additions & 0 deletions src/api/providers/fetchers/modelCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { getLMStudioModels } from "./lmstudio"
import { getIOIntelligenceModels } from "./io-intelligence"
import { getDeepInfraModels } from "./deepinfra"
import { getHuggingFaceModels } from "./huggingface"
import { getRooModels } from "./roo"

const memoryCache = new NodeCache({ stdTTL: 5 * 60, checkperiod: 5 * 60 })

Expand Down Expand Up @@ -99,6 +100,13 @@ export const getModels = async (options: GetModelsOptions): Promise<ModelRecord>
case "huggingface":
models = await getHuggingFaceModels()
break
case "roo":
// Roo Code Cloud provider requires baseUrl and optional apiKey
models = await getRooModels(
options.baseUrl ?? process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy",
options.apiKey,
)
break
default: {
// Ensures router is exhaustively checked if RouterName is a strict union.
const exhaustiveCheck: never = provider
Expand Down
77 changes: 77 additions & 0 deletions src/api/providers/fetchers/roo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import axios from "axios"

import type { ModelRecord } from "../../../shared/api"

import { DEFAULT_HEADERS } from "../constants"

/**
* Fetches available models from the Roo Code Cloud provider
*
* @param baseUrl The base URL of the Roo Code Cloud provider
* @param apiKey The API key (session token) for the Roo Code Cloud provider
* @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 getRooModels(baseUrl: string, apiKey?: string): Promise<ModelRecord> {
try {
const headers: Record<string, string> = {
"Content-Type": "application/json",
...DEFAULT_HEADERS,
}

if (apiKey) {
headers["Authorization"] = `Bearer ${apiKey}`
}

// Normalize the URL to ensure proper /v1/models endpoint construction
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't harming anything, but probably isn't doing any good for a provider that doesn't take a user specified base url.

const urlObj = new URL(baseUrl)
urlObj.pathname = urlObj.pathname.replace(/\/+$/, "").replace(/\/+/g, "/") + "/v1/models"
const url = urlObj.href

// Added timeout to prevent indefinite hanging
const response = await axios.get(url, { headers, timeout: 10000 })
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a blocker but iirc fetch() with an abort signal covers a wider range of timeout cases than axios.

const models: ModelRecord = {}

// Process the model info from the response
// Expected format: { object: "list", data: [{ id: string, object: "model", created: number, owned_by: string }] }
if (response.data && response.data.data && Array.isArray(response.data.data)) {
for (const model of response.data.data) {
const modelId = model.id

if (!modelId) continue

// For Roo Code Cloud, we provide basic model info
// The actual detailed model info is stored in the static rooModels definition
// This just confirms which models are available
models[modelId] = {
maxTokens: 16_384, // Default fallback
contextWindow: 262_144, // Default fallback
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0,
outputPrice: 0,
description: `Model available through Roo Code Cloud`,
}
}
} else {
// If response.data.data is not in the expected format, consider it an error.
console.error("Error fetching Roo Code Cloud models: Unexpected response format", response.data)
throw new Error("Failed to fetch Roo Code Cloud models: Unexpected response format.")
}

return models
} catch (error: any) {
console.error("Error fetching Roo Code Cloud models:", error.message ? error.message : error)
if (axios.isAxiosError(error) && error.response) {
throw new Error(
`Failed to fetch Roo Code Cloud 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 Roo Code Cloud models: No response from server. Check Roo Code Cloud server status and base URL.",
)
} else {
throw new Error(`Failed to fetch Roo Code Cloud models: ${error.message || "An unknown error occurred."}`)
}
}
}
46 changes: 38 additions & 8 deletions src/api/providers/roo.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"

import { AuthState, rooDefaultModelId, rooModels, type RooModelId } from "@roo-code/types"
import { AuthState, rooDefaultModelId, rooModels, type RooModelId, type ModelInfo } from "@roo-code/types"
import { CloudService } from "@roo-code/cloud"

import type { ApiHandlerOptions } from "../../shared/api"
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
import { ApiStream } from "../transform/stream"

import type { ApiHandlerCreateMessageMetadata } from "../index"
import { DEFAULT_HEADERS } from "./constants"
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
import { getModels } from "../providers/fetchers/modelCache"

export class RooHandler extends BaseOpenAiCompatibleProvider<RooModelId> {
export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
private authStateListener?: (state: { state: AuthState }) => void
private mergedModels: Record<string, ModelInfo> = rooModels as Record<string, ModelInfo>
private modelsLoaded = false

constructor(options: ApiHandlerOptions) {
let sessionToken: string | undefined = undefined
Expand All @@ -21,18 +24,25 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<RooModelId> {
sessionToken = CloudService.instance.authService?.getSessionToken()
}

const baseURL = process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy"

// Always construct the handler, even without a valid token.
// The provider-proxy server will return 401 if authentication fails.
super({
...options,
providerName: "Roo Code Cloud",
baseURL: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy/v1",
baseURL,
apiKey: sessionToken || "unauthenticated", // Use a placeholder if no token.
defaultProviderModelId: rooDefaultModelId,
providerModels: rooModels,
providerModels: rooModels as Record<string, ModelInfo>,
defaultTemperature: 0.7,
})

// Load dynamic models asynchronously
this.loadDynamicModels(baseURL, sessionToken).catch((error) => {
console.error("[RooHandler] Failed to load dynamic models:", error)
})

if (CloudService.hasInstance()) {
const cloudService = CloudService.instance

Expand Down Expand Up @@ -103,17 +113,37 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<RooModelId> {
}
}

private async loadDynamicModels(baseURL: string, apiKey?: string): Promise<void> {
try {
const dynamicModels = await getModels({
provider: "roo",
baseUrl: baseURL,
apiKey,
})
this.modelsLoaded = true

// Merge dynamic models with static models, preferring static model info
this.mergedModels = { ...dynamicModels, ...rooModels } as Record<string, ModelInfo>
} catch (error) {
console.error("[RooHandler] Error loading dynamic models:", error)
// Keep using static models as fallback
this.modelsLoaded = false
}
}

override getModel() {
const modelId = this.options.apiModelId || rooDefaultModelId
const modelInfo = this.providerModels[modelId as RooModelId] ?? this.providerModels[rooDefaultModelId]

// Try to find the model in the merged models (which includes both static and dynamic)
const modelInfo = this.mergedModels[modelId]

if (modelInfo) {
return { id: modelId as RooModelId, info: modelInfo }
return { id: modelId, info: modelInfo }
}

// Return the requested model ID even if not found, with fallback info.
return {
id: modelId as RooModelId,
id: modelId,
info: {
maxTokens: 16_384,
contextWindow: 262_144,
Expand Down
1 change: 1 addition & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,7 @@ export const webviewMessageHandler = async (
glama: {},
ollama: {},
lmstudio: {},
roo: {},
}

const safeGetModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
Expand Down
1 change: 1 addition & 0 deletions src/shared/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ const dynamicProviderExtras = {
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
roo: {} as { apiKey?: string; baseUrl?: string },
} as const satisfies Record<RouterName, object>

// Build the dynamic options union from the map, intersected with CommonFetchParams
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/utils/__tests__/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ describe("Model Validation Functions", () => {
"io-intelligence": {},
"vercel-ai-gateway": {},
huggingface: {},
roo: {},
}

const allowAllOrganization: OrganizationAllowList = {
Expand Down
Loading