From 27f404426c595d67b07c2a10b66e810f6b1494ee Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 7 Nov 2025 18:14:05 -0700 Subject: [PATCH] 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 --- packages/types/src/model.ts | 5 ++ .../__tests__/openai-native-merge.spec.ts | 51 +++++++++++++ packages/types/src/providers/openai.ts | 71 +++++++++++++++++++ src/api/providers/openai-native.ts | 55 +++++++++++++- src/core/task/Task.ts | 12 ++-- src/core/webview/webviewMessageHandler.ts | 58 +++++++++++++++ src/shared/ExtensionMessage.ts | 3 + src/shared/WebviewMessage.ts | 2 + src/shared/__tests__/api.spec.ts | 48 ++++++++++++- src/shared/api.ts | 23 ++++-- .../src/components/settings/ApiOptions.tsx | 11 ++- .../components/settings/ThinkingBudget.tsx | 20 +++--- .../ui/hooks/useOpenAiNativeModels.ts | 40 +++++++++++ .../components/ui/hooks/useSelectedModel.ts | 17 +++-- 14 files changed, 389 insertions(+), 27 deletions(-) create mode 100644 packages/types/src/providers/__tests__/openai-native-merge.spec.ts create mode 100644 webview-ui/src/components/ui/hooks/useOpenAiNativeModels.ts diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 748acb2858..ceabae9236 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -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(), diff --git a/packages/types/src/providers/__tests__/openai-native-merge.spec.ts b/packages/types/src/providers/__tests__/openai-native-merge.spec.ts new file mode 100644 index 0000000000..15ded4b660 --- /dev/null +++ b/packages/types/src/providers/__tests__/openai-native-merge.spec.ts @@ -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 + }) +}) diff --git a/packages/types/src/providers/openai.ts b/packages/types/src/providers/openai.ts index a3eed1b57c..f30701f62b 100644 --- a/packages/types/src/providers/openai.ts +++ b/packages/types/src/providers/openai.ts @@ -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 { + 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 = {} + 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 = {} + 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 { + // 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 diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index daf6278822..4da50aed64 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -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 // 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 { + try { + // 1) Inline JSON override + let extras: Record = {} + 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 + } + } + } 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 + } + } + } catch { + // ignore file errors + } + } + + return { ...openAiNativeModels, ...extras } as Record + } catch { + return openAiNativeModels as Record + } +} + 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", diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7ed317da10..165ae91a43 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -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 implements TaskLike { throw new Error("Auto-approval limit reached and user did not approve continuation") } - // Determine GPT‑5 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 GPT‑5 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 implements TaskLike { private async persistGpt5Metadata(): Promise { 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 } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 847ea1a161..040e99b4ce 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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> { + try { + // 1) Env JSON override + let extras: Record = {} + 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 + } catch { + return openAiNativeModels as Record + } + } + /** * 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? diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index c3926d5073..0647f73f20 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -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 ollamaModels?: ModelRecord lmStudioModels?: ModelRecord vsCodeLmModels?: { vendor?: string; family?: string; version?: string; id?: string }[] diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index f10808cd42..57c6898402 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -67,10 +67,12 @@ export interface WebviewMessage { | "flushRouterModels" | "requestRouterModels" | "requestOpenAiModels" + | "requestOpenAiNativeModels" | "requestOllamaModels" | "requestLmStudioModels" | "requestRooModels" | "requestVsCodeLmModels" + | "openAiNativeModels" | "requestHuggingFaceModels" | "openImage" | "saveImage" diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index aaeb1bf444..a456a6b62c 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -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, diff --git a/src/shared/api.ts b/src/shared/api.ts index 802654adaa..5c3bec00ac 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -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 GPT‑5 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 + } +} diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 28eec04180..a9099b2b1c 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -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) => { diff --git a/webview-ui/src/components/settings/ThinkingBudget.tsx b/webview-ui/src/components/settings/ThinkingBudget.tsx index 2716d42220..c8d57fa8fc 100644 --- a/webview-ui/src/components/settings/ThinkingBudget.tsx +++ b/webview-ui/src/components/settings/ThinkingBudget.tsx @@ -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) { diff --git a/webview-ui/src/components/ui/hooks/useOpenAiNativeModels.ts b/webview-ui/src/components/ui/hooks/useOpenAiNativeModels.ts new file mode 100644 index 0000000000..5f02aec908 --- /dev/null +++ b/webview-ui/src/components/ui/hooks/useOpenAiNativeModels.ts @@ -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 + +/** + * 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({ + queryKey: ["openAiNativeModels"], + queryFn: () => { + return new Promise((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, + }) +} diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index c2a57942d2..af60e69f2a 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -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, 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 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": {