fix: Load user-defined OpenAI Native models from ~/.roo/models/openai-native.json

- Fixes context window and cost calculation for custom OpenAI Native models
- Adds dynamic model loading from ~/.roo/models/openai-native.json
- Enables 'Minimal (Fastest)' reasoning option for models with reasoningEffort: 'minimal'
- Adds new ModelInfo capability flags: useFullMaxTokens, enableResponseContinuity
- Implements requestOpenAiNativeModels message flow for UI updates
- All tests passing
This commit is contained in:
Hannes Rudolph 2025-11-07 18:14:05 -07:00
parent f93aafefe1
commit 27f404426c
14 changed files with 389 additions and 27 deletions

View file

@ -70,6 +70,11 @@ export const modelInfoSchema = z.object({
supportsReasoningEffort: z.boolean().optional(),
requiredReasoningEffort: z.boolean().optional(),
preserveReasoning: z.boolean().optional(),
// Generic capability flags (user-extensible via ~/.roo/models/*.json)
// When true, bypass 20% cap in getModelMaxOutputTokens
useFullMaxTokens: z.boolean().optional(),
// When true, enable previous_response_id continuity on OpenAI Responses API
enableResponseContinuity: z.boolean().optional(),
supportedParameters: z.array(modelParametersSchema).optional(),
inputPrice: z.number().optional(),
outputPrice: z.number().optional(),

View file

@ -0,0 +1,51 @@
/* eslint-disable */
import fs from "fs/promises"
import path from "path"
import os from "os"
import { getOpenAiNativeModels, openAiNativeModels } from "../openai.js"
describe("getOpenAiNativeModels()", () => {
test("returns built-ins when no user file exists", () => {
delete (process as any).env.ROO_OPENAI_NATIVE_MODELS_PATH
const models = getOpenAiNativeModels()
// Should at least contain all built-ins
for (const key of Object.keys(openAiNativeModels)) {
expect(models).toHaveProperty(key)
}
})
test("merges extras from ROO_OPENAI_NATIVE_MODELS_JSON env", () => {
const extras = {
"custom/openai-native-test": {
maxTokens: 4096,
contextWindow: 8192,
supportsPromptCache: false,
supportsImages: false,
description: "Custom OpenAI Native test model",
},
}
;(process as any).env.ROO_OPENAI_NATIVE_MODELS_JSON = JSON.stringify(extras)
const models = getOpenAiNativeModels()
expect(models["custom/openai-native-test"]).toBeDefined()
expect(models["custom/openai-native-test"]?.description).toBe("Custom OpenAI Native test model")
delete (process as any).env.ROO_OPENAI_NATIVE_MODELS_JSON
})
test("ignores invalid JSON and returns built-ins", async () => {
const tmp = path.join(os.tmpdir(), `openai-native-invalid-${Date.now()}.json`)
;(process as any).env.ROO_OPENAI_NATIVE_MODELS_PATH = tmp
await fs.writeFile(tmp, "{not-json", "utf8")
const models = getOpenAiNativeModels()
// Should not throw and should still include built-ins
for (const key of Object.keys(openAiNativeModels)) {
expect(models).toHaveProperty(key)
}
await fs.unlink(tmp).catch(() => {})
delete (process as any).env.ROO_OPENAI_NATIVE_MODELS_PATH
})
})

View file

@ -316,6 +316,77 @@ export const openAiModelInfoSaneDefaults: ModelInfo = {
// https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#api-specs
export const azureOpenAiDefaultApiVersion = "2024-08-01-preview"
/**
* Load user-defined OpenAI native models from ~/.roo/models/openai-native.json (or ROO_OPENAI_NATIVE_MODELS_PATH).
* Uses dynamic requires so the types package remains browser-safe for the webview bundle.
*/
function loadUserOpenAiNativeModels(): Record<string, ModelInfo> {
try {
// 1) Environment JSON override (works in ESM/browser-safe contexts)
const inlineJson = (typeof process !== "undefined" && process.env?.ROO_OPENAI_NATIVE_MODELS_JSON) || ""
if (inlineJson) {
try {
const parsed = JSON.parse(inlineJson)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const envResult: Record<string, ModelInfo> = {}
for (const [modelId, info] of Object.entries(parsed)) {
if (info && typeof info === "object" && !Array.isArray(info)) {
envResult[modelId] = info as ModelInfo
}
}
return envResult
}
} catch {
// ignore malformed env JSON and continue to file-based load
}
}
// 2) Node file-based load (CJS-friendly; best-effort in ESM via require fallback)
const req: NodeRequire | null =
typeof Function !== "undefined" ? Function("try{return require}catch{return null}")() : null
if (!req || typeof process === "undefined" || !process?.versions?.node) return {}
const fs: typeof import("fs") | undefined = req("fs")
const path: typeof import("path") | undefined = req("path")
const os: typeof import("os") | undefined = req("os")
if (!fs || !path) return {}
const customPath =
process.env.ROO_OPENAI_NATIVE_MODELS_PATH ||
(os?.homedir ? path.join(os.homedir(), ".roo", "models", "openai-native.json") : undefined)
if (!customPath) return {}
if (!fs.existsSync?.(customPath)) return {}
const raw = fs.readFileSync(customPath, "utf8")
const parsed = JSON.parse(raw)
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}
// Best-effort shallow validation; only keep object entries
const result: Record<string, ModelInfo> = {}
for (const [modelId, info] of Object.entries(parsed)) {
if (info && typeof info === "object" && !Array.isArray(info)) {
result[modelId] = info as ModelInfo
}
}
return result
} catch {
// On any error (missing file, invalid JSON, restricted env), fall back to empty extras
return {}
}
}
/**
* Returns built-in OpenAI native models merged with user-defined additions.
* User models override built-ins on key collision.
*/
export function getOpenAiNativeModels(): Record<string, ModelInfo> {
// Merge each call to pick up changes without caching; cost is negligible
const extras = loadUserOpenAiNativeModels()
return { ...openAiNativeModels, ...(extras || {}) }
}
export const OPENAI_NATIVE_DEFAULT_TEMPERATURE = 0
export const GPT5_DEFAULT_TEMPERATURE = 1.0

View file

@ -1,5 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import * as fsSync from "fs"
import * as pathSync from "path"
import * as osSync from "os"
import {
type ModelInfo,
@ -31,6 +34,50 @@ export type OpenAiNativeModel = ReturnType<OpenAiNativeHandler["getModel"]>
// Constants for model identification
const GPT5_MODEL_PREFIX = "gpt-5"
/**
* Host-only sync loader for OpenAI Native models merged with ~/.roo overrides.
* Avoids the browser-safe loader in @roo-code/types which may not resolve extras in the extension host bundle.
*/
function loadMergedOpenAiNativeModelsOnHostSync(): Record<string, ModelInfo> {
try {
// 1) Inline JSON override
let extras: Record<string, ModelInfo> = {}
try {
const inline = process.env?.ROO_OPENAI_NATIVE_MODELS_JSON
if (inline) {
const parsed = JSON.parse(inline)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
extras = parsed as Record<string, ModelInfo>
}
}
} catch {
// ignore
}
// 2) File-based load when no inline provided
if (Object.keys(extras).length === 0) {
try {
const customPath =
process.env.ROO_OPENAI_NATIVE_MODELS_PATH ||
pathSync.join(osSync.homedir(), ".roo", "models", "openai-native.json")
if (customPath && fsSync.existsSync(customPath)) {
const raw = fsSync.readFileSync(customPath, "utf8")
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
extras = parsed as Record<string, ModelInfo>
}
}
} catch {
// ignore file errors
}
}
return { ...openAiNativeModels, ...extras } as Record<string, ModelInfo>
} catch {
return openAiNativeModels as Record<string, ModelInfo>
}
}
export class OpenAiNativeHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private client: OpenAI
@ -1221,10 +1268,12 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
override getModel() {
const modelId = this.options.apiModelId
let id =
modelId && modelId in openAiNativeModels ? (modelId as OpenAiNativeModelId) : openAiNativeDefaultModelId
// Get merged models (built-in + user-defined from ~/.roo/models/openai-native.json)
const allModels = loadMergedOpenAiNativeModelsOnHostSync()
const info: ModelInfo = openAiNativeModels[id]
let id = modelId && modelId in allModels ? (modelId as OpenAiNativeModelId) : openAiNativeDefaultModelId
const info: ModelInfo = allModels[id]
const params = getModelParams({
format: "openai",

View file

@ -58,7 +58,7 @@ import { ClineAskResponse } from "../../shared/WebviewMessage"
import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes"
import { DiffStrategy } from "../../shared/tools"
import { EXPERIMENT_IDS, experiments } from "../../shared/experiments"
import { getModelMaxOutputTokens } from "../../shared/api"
import { getModelMaxOutputTokens, shouldUseResponseContinuity } from "../../shared/api"
// services
import { UrlContentFetcher } from "../../services/browser/UrlContentFetcher"
@ -2807,12 +2807,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
throw new Error("Auto-approval limit reached and user did not approve continuation")
}
// Determine GPT5 previous_response_id from last persisted assistant turn (if available),
// unless a condense just occurred (skip once after condense).
// Determine previous_response_id continuity from last persisted assistant turn (if available),
// for GPT5 family or models that opt-in via enableResponseContinuity; skip if a condense just occurred.
let previousResponseId: string | undefined = undefined
try {
const modelId = this.api.getModel().id
if (modelId && modelId.startsWith("gpt-5") && !this.skipPrevResponseIdOnce) {
const modelInfo = this.api.getModel().info
if (!this.skipPrevResponseIdOnce && shouldUseResponseContinuity({ modelId, model: modelInfo })) {
// Find the last assistant message that has a previous_response_id stored
const idx = findLastIndex(
this.clineMessages,
@ -3060,7 +3061,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
private async persistGpt5Metadata(): Promise<void> {
try {
const modelId = this.api.getModel().id
if (!modelId || !modelId.startsWith("gpt-5")) return
const modelInfo = this.api.getModel().info
if (!shouldUseResponseContinuity({ modelId, model: modelInfo })) return
// Check if the API handler has a getLastResponseId method (OpenAiNativeHandler specific)
const handler = this.api as ApiHandler & { getLastResponseId?: () => string | undefined }

View file

@ -10,6 +10,7 @@ import {
type GlobalState,
type ClineMessage,
type TelemetrySetting,
type ModelInfo,
TelemetryEventName,
UserSettingsConfig,
DEFAULT_CHECKPOINT_TIMEOUT_SECONDS,
@ -53,6 +54,7 @@ import { getWorkspacePath } from "../../utils/path"
import { Mode, defaultModeSlug } from "../../shared/modes"
import { getModels, flushModels } from "../../api/providers/fetchers/modelCache"
import { GetModelsOptions } from "../../shared/api"
import { openAiNativeModels } from "@roo-code/types"
import { generateSystemPrompt } from "./generateSystemPrompt"
import { getCommand } from "../../utils/commands"
@ -97,6 +99,51 @@ export const webviewMessageHandler = async (
)
}
/**
* Host-only loader: merge built-in OpenAI native models with user additions from:
* - ROO_OPENAI_NATIVE_MODELS_JSON (JSON string)
* - ROO_OPENAI_NATIVE_MODELS_PATH or ~/.roo/models/openai-native.json
* Returns the merged models record.
*/
async function getMergedOpenAiNativeModelsOnHost(): Promise<Record<string, ModelInfo>> {
try {
// 1) Env JSON override
let extras: Record<string, any> = {}
const inline = process.env?.ROO_OPENAI_NATIVE_MODELS_JSON
if (inline) {
try {
const parsed = JSON.parse(inline)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
extras = parsed
}
} catch {
// ignore malformed env json
}
}
// 2) File-based
if (Object.keys(extras).length === 0) {
const customPath =
process.env.ROO_OPENAI_NATIVE_MODELS_PATH ||
path.join(os.homedir(), ".roo", "models", "openai-native.json")
try {
const raw = await fs.readFile(customPath, "utf8")
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
extras = parsed
}
} catch {
// missing/invalid file: ignore
}
}
const merged = { ...openAiNativeModels, ...extras }
return merged as Record<string, ModelInfo>
} catch {
return openAiNativeModels as Record<string, ModelInfo>
}
}
/**
* Removes the target message and all subsequent messages
*/
@ -980,6 +1027,17 @@ export const webviewMessageHandler = async (
}
break
case "requestOpenAiNativeModels": {
// Return merged built-ins + user-defined from ~/.roo/models/openai-native.json
try {
const openAiNativeModels = await getMergedOpenAiNativeModelsOnHost()
provider.postMessageToWebview({ type: "openAiNativeModels", openAiNativeModels })
} catch (error) {
console.error("Failed to load OpenAI Native models:", error)
provider.postMessageToWebview({ type: "openAiNativeModels", openAiNativeModels: {} })
}
break
}
case "requestVsCodeLmModels":
const vsCodeLmModels = await getVsCodeLmModels()
// TODO: Cache like we do for OpenRouter, etc?

View file

@ -14,6 +14,7 @@ import type {
OrganizationAllowList,
ShareVisibility,
QueuedMessage,
ModelInfo,
} from "@roo-code/types"
import { GitCommit } from "../utils/git"
@ -77,6 +78,7 @@ export interface ExtensionMessage {
| "listApiConfig"
| "routerModels"
| "openAiModels"
| "openAiNativeModels"
| "ollamaModels"
| "lmStudioModels"
| "vsCodeLmModels"
@ -158,6 +160,7 @@ export interface ExtensionMessage {
clineMessage?: ClineMessage
routerModels?: RouterModels
openAiModels?: string[]
openAiNativeModels?: Record<string, ModelInfo>
ollamaModels?: ModelRecord
lmStudioModels?: ModelRecord
vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[]

View file

@ -67,10 +67,12 @@ export interface WebviewMessage {
| "flushRouterModels"
| "requestRouterModels"
| "requestOpenAiModels"
| "requestOpenAiNativeModels"
| "requestOllamaModels"
| "requestLmStudioModels"
| "requestRooModels"
| "requestVsCodeLmModels"
| "openAiNativeModels"
| "requestHuggingFaceModels"
| "openImage"
| "saveImage"

View file

@ -5,7 +5,12 @@ import {
ANTHROPIC_DEFAULT_MAX_TOKENS,
} from "@roo-code/types"
import { getModelMaxOutputTokens, shouldUseReasoningBudget, shouldUseReasoningEffort } from "../api"
import {
getModelMaxOutputTokens,
shouldUseReasoningBudget,
shouldUseReasoningEffort,
shouldUseResponseContinuity,
} from "../api"
describe("getModelMaxOutputTokens", () => {
const mockModel: ModelInfo = {
@ -283,6 +288,47 @@ describe("getModelMaxOutputTokens", () => {
})
})
test("should bypass 20% cap when model.useFullMaxTokens is true (non-GPT-5 id)", () => {
const model: ModelInfo = {
contextWindow: 200_000,
supportsPromptCache: false,
maxTokens: 128_000,
useFullMaxTokens: true,
}
const result = getModelMaxOutputTokens({
modelId: "vendor/special-model",
model,
settings: { apiProvider: "openai" },
format: "openai",
})
expect(result).toBe(128_000)
})
test("shouldUseResponseContinuity returns true for GPT-5 ids", () => {
const model: ModelInfo = {
contextWindow: 100_000,
supportsPromptCache: true,
}
expect(shouldUseResponseContinuity({ modelId: "gpt-5-turbo", model })).toBe(true)
})
test("shouldUseResponseContinuity returns true when model.enableResponseContinuity is true", () => {
const model: ModelInfo = {
contextWindow: 100_000,
supportsPromptCache: true,
enableResponseContinuity: true,
}
expect(shouldUseResponseContinuity({ modelId: "not-gpt", model })).toBe(true)
})
test("shouldUseResponseContinuity returns false otherwise", () => {
const model: ModelInfo = {
contextWindow: 100_000,
supportsPromptCache: true,
}
expect(shouldUseResponseContinuity({ modelId: "other-model", model })).toBe(false)
})
test("should return modelMaxTokens from settings when reasoning budget is required", () => {
const model: ModelInfo = {
contextWindow: 200_000,

View file

@ -118,15 +118,16 @@ export const getModelMaxOutputTokens = ({
// If model has explicit maxTokens, clamp it to 20% of the context window
// Exception: GPT-5 models should use their exact configured max output tokens
if (model.maxTokens) {
// Check if this is a GPT-5 model (case-insensitive)
// Check if this is a GPT-5 model (case-insensitive) OR a model opting-in via capability flag
const isGpt5Model = modelId.toLowerCase().includes("gpt-5")
const bypassCap = !!model.useFullMaxTokens || isGpt5Model
// GPT-5 models bypass the 20% cap and use their full configured max tokens
if (isGpt5Model) {
// Bypass the 20% cap and use the full configured max tokens
if (bypassCap) {
return model.maxTokens
}
// All other models are clamped to 20% of context window
// Otherwise clamp to 20% of context window
return Math.min(model.maxTokens, Math.ceil(model.contextWindow * 0.2))
}
@ -172,3 +173,17 @@ const dynamicProviderExtras = {
export type GetModelsOptions = {
[P in keyof typeof dynamicProviderExtras]: ({ provider: P } & (typeof dynamicProviderExtras)[P]) & CommonFetchParams
}[RouterName]
/**
* Should we enable OpenAI Responses API continuity (previous_response_id)?
* True for GPT5 family by default, or when the model opts in via ModelInfo.enableResponseContinuity.
*/
export const shouldUseResponseContinuity = ({ modelId, model }: { modelId: string; model: ModelInfo }): boolean => {
try {
if (!modelId) return false
if (model?.enableResponseContinuity) return true
return modelId.startsWith("gpt-5")
} catch {
return false
}
}

View file

@ -45,6 +45,7 @@ import { validateApiConfigurationExcludingModelErrors, getModelValidationError }
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useRouterModels } from "@src/components/ui/hooks/useRouterModels"
import { useSelectedModel } from "@src/components/ui/hooks/useSelectedModel"
import { useOpenAiNativeModels } from "@src/components/ui/hooks/useOpenAiNativeModels"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import {
useOpenRouterModelProviders,
@ -190,6 +191,9 @@ const ApiOptions = ({
info: selectedModelInfo,
} = useSelectedModel(apiConfiguration)
// Fetch merged OpenAI Native models for dropdown options
const { data: openAiNativeRecord } = useOpenAiNativeModels(selectedProvider === "openai-native")
const { data: routerModels, refetch: refetchRouterModels } = useRouterModels()
const { data: openRouterModelProviders } = useOpenRouterModelProviders(
@ -270,7 +274,10 @@ const ApiOptions = ({
}, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage])
const selectedProviderModels = useMemo(() => {
const models = MODELS_BY_PROVIDER[selectedProvider]
const models =
selectedProvider === "openai-native"
? openAiNativeRecord || MODELS_BY_PROVIDER[selectedProvider]
: MODELS_BY_PROVIDER[selectedProvider]
if (!models) return []
const filteredModels = filterModels(models, selectedProvider, organizationAllowList)
@ -292,7 +299,7 @@ const ApiOptions = ({
: []
return availableModels
}, [selectedProvider, organizationAllowList, selectedModelId])
}, [selectedProvider, organizationAllowList, selectedModelId, openAiNativeRecord])
const onProviderChange = useCallback(
(value: ProviderName) => {

View file

@ -33,10 +33,13 @@ const shouldShowMinimalOption = (
provider: string | undefined,
modelId: string | undefined,
supportsEffort: boolean | undefined,
modelDefaultEffort?: ReasoningEffortWithMinimal | undefined,
): boolean => {
const isGpt5Model = provider === "openai-native" && modelId?.startsWith("gpt-5")
const isOpenRouterWithEffort = provider === "openrouter" && supportsEffort === true
return !!(isGpt5Model || isOpenRouterWithEffort)
// Gate for OpenAI Native via model JSON: show when default effort is explicitly "minimal"
const isOpenAiNativeMinimal = provider === "openai-native" && modelDefaultEffort === "minimal"
return !!(isGpt5Model || isOpenRouterWithEffort || isOpenAiNativeMinimal)
}
export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, modelInfo }: ThinkingBudgetProps) => {
@ -53,11 +56,19 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
const isReasoningBudgetRequired = !!modelInfo && modelInfo.requiredReasoningBudget
const isReasoningEffortSupported = !!modelInfo && modelInfo.supportsReasoningEffort
// Default reasoning effort - use model's default if available
// GPT-5 models have "medium" as their default in the model configuration
const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortWithMinimal | undefined
const defaultReasoningEffort: ReasoningEffortWithMinimal = modelDefaultReasoningEffort || "medium"
const currentReasoningEffort: ReasoningEffortWithMinimal =
(apiConfiguration.reasoningEffort as ReasoningEffortWithMinimal | undefined) || defaultReasoningEffort
// Determine if minimal option should be shown
const showMinimalOption = shouldShowMinimalOption(
apiConfiguration.apiProvider,
selectedModelId,
isReasoningEffortSupported,
modelDefaultReasoningEffort,
)
// Build available reasoning efforts list
@ -66,13 +77,6 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
? (["minimal", ...baseEfforts] as ReasoningEffortWithMinimal[])
: baseEfforts
// Default reasoning effort - use model's default if available
// GPT-5 models have "medium" as their default in the model configuration
const modelDefaultReasoningEffort = modelInfo?.reasoningEffort as ReasoningEffortWithMinimal | undefined
const defaultReasoningEffort: ReasoningEffortWithMinimal = modelDefaultReasoningEffort || "medium"
const currentReasoningEffort: ReasoningEffortWithMinimal =
(apiConfiguration.reasoningEffort as ReasoningEffortWithMinimal | undefined) || defaultReasoningEffort
// Set default reasoning effort when model supports it and no value is set
useEffect(() => {
if (isReasoningEffortSupported && !apiConfiguration.reasoningEffort && defaultReasoningEffort) {

View file

@ -0,0 +1,40 @@
import { useQuery } from "@tanstack/react-query"
import type { ModelInfo } from "@roo-code/types"
import { vscode } from "@src/utils/vscode"
export type OpenAiNativeModelsRecord = Record<string, ModelInfo>
/**
* Hook to fetch OpenAI Native models (built-in + custom from ~/.roo/models/openai-native.json)
* from the extension host.
*/
export function useOpenAiNativeModels(enabled: boolean = true) {
return useQuery<OpenAiNativeModelsRecord | undefined>({
queryKey: ["openAiNativeModels"],
queryFn: () => {
return new Promise<OpenAiNativeModelsRecord | undefined>((resolve) => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "openAiNativeModels") {
window.removeEventListener("message", handleMessage)
resolve(message.openAiNativeModels || undefined)
}
}
window.addEventListener("message", handleMessage)
// Request the models from the host
vscode.postMessage({ type: "requestOpenAiNativeModels" })
// Timeout after 5 seconds
setTimeout(() => {
window.removeEventListener("message", handleMessage)
resolve(undefined)
}, 5000)
})
},
enabled,
staleTime: Infinity, // Models don't change frequently
retry: false,
})
}

View file

@ -11,7 +11,6 @@ import {
geminiModels,
mistralModels,
openAiModelInfoSaneDefaults,
openAiNativeModels,
vertexModels,
xaiModels,
groqModels,
@ -37,6 +36,7 @@ import { useRouterModels } from "./useRouterModels"
import { useOpenRouterModelProviders } from "./useOpenRouterModelProviders"
import { useLmStudioModels } from "./useLmStudioModels"
import { useOllamaModels } from "./useOllamaModels"
import { useOpenAiNativeModels } from "./useOpenAiNativeModels"
/**
* Helper to get a validated model ID for dynamic providers.
@ -66,12 +66,14 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => {
const openRouterModelProviders = useOpenRouterModelProviders(openRouterModelId)
const lmStudioModels = useLmStudioModels(lmStudioModelId)
const ollamaModels = useOllamaModels(ollamaModelId)
const openAiNativeModels = useOpenAiNativeModels(provider === "openai-native")
// Compute readiness only for the data actually needed for the selected provider
const needRouterModels = shouldFetchRouterModels
const needOpenRouterProviders = provider === "openrouter"
const needLmStudio = typeof lmStudioModelId !== "undefined"
const needOllama = typeof ollamaModelId !== "undefined"
const needOpenAiNative = provider === "openai-native"
const hasValidRouterData = needRouterModels
? routerModels.data &&
@ -83,6 +85,7 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => {
const isReady =
(!needLmStudio || typeof lmStudioModels.data !== "undefined") &&
(!needOllama || typeof ollamaModels.data !== "undefined") &&
(!needOpenAiNative || typeof openAiNativeModels.data !== "undefined") &&
hasValidRouterData &&
(!needOpenRouterProviders || typeof openRouterModelProviders.data !== "undefined")
@ -95,6 +98,7 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => {
openRouterModelProviders: (openRouterModelProviders.data || {}) as Record<string, ModelInfo>,
lmStudioModels: (lmStudioModels.data || undefined) as ModelRecord | undefined,
ollamaModels: (ollamaModels.data || undefined) as ModelRecord | undefined,
openAiNativeModels: (openAiNativeModels.data || undefined) as ModelRecord | undefined,
})
: { id: getProviderDefaultModelId(provider), info: undefined }
@ -106,12 +110,14 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => {
(needRouterModels && routerModels.isLoading) ||
(needOpenRouterProviders && openRouterModelProviders.isLoading) ||
(needLmStudio && lmStudioModels!.isLoading) ||
(needOllama && ollamaModels!.isLoading),
(needOllama && ollamaModels!.isLoading) ||
(needOpenAiNative && openAiNativeModels!.isLoading),
isError:
(needRouterModels && routerModels.isError) ||
(needOpenRouterProviders && openRouterModelProviders.isError) ||
(needLmStudio && lmStudioModels!.isError) ||
(needOllama && ollamaModels!.isError),
(needOllama && ollamaModels!.isError) ||
(needOpenAiNative && openAiNativeModels!.isError),
}
}
@ -122,6 +128,7 @@ function getSelectedModel({
openRouterModelProviders,
lmStudioModels,
ollamaModels,
openAiNativeModels,
}: {
provider: ProviderName
apiConfiguration: ProviderSettings
@ -129,6 +136,7 @@ function getSelectedModel({
openRouterModelProviders: Record<string, ModelInfo>
lmStudioModels: ModelRecord | undefined
ollamaModels: ModelRecord | undefined
openAiNativeModels: ModelRecord | undefined
}): { id: string; info: ModelInfo | undefined } {
// the `undefined` case are used to show the invalid selection to prevent
// users from seeing the default model if their selection is invalid
@ -260,7 +268,8 @@ function getSelectedModel({
}
case "openai-native": {
const id = apiConfiguration.apiModelId ?? defaultModelId
const info = openAiNativeModels[id as keyof typeof openAiNativeModels]
// Use dynamically loaded models (built-in + custom from ~/.roo/models/openai-native.json)
const info = openAiNativeModels?.[id]
return { id, info }
}
case "mistral": {