mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
f93aafefe1
commit
27f404426c
14 changed files with 389 additions and 27 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 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<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 }
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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 }[]
|
||||
|
|
|
|||
|
|
@ -67,10 +67,12 @@ export interface WebviewMessage {
|
|||
| "flushRouterModels"
|
||||
| "requestRouterModels"
|
||||
| "requestOpenAiModels"
|
||||
| "requestOpenAiNativeModels"
|
||||
| "requestOllamaModels"
|
||||
| "requestLmStudioModels"
|
||||
| "requestRooModels"
|
||||
| "requestVsCodeLmModels"
|
||||
| "openAiNativeModels"
|
||||
| "requestHuggingFaceModels"
|
||||
| "openImage"
|
||||
| "saveImage"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
40
webview-ui/src/components/ui/hooks/useOpenAiNativeModels.ts
Normal file
40
webview-ui/src/components/ui/hooks/useOpenAiNativeModels.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue