feat: centralize model family defaults for tool preferences across all providers

This PR addresses Issue #10214 by creating a centralized framework for
applying model family defaults to tool preferences and settings.

Key changes:
- Added model-family-defaults.ts with MODEL_FAMILY_REGISTRY and applyModelFamilyDefaults()
- Added applyModelDefaults() helper method to BaseProvider
- Updated all provider getModel() methods to apply family defaults
- Removed deprecated router-tool-preferences.ts (functionality consolidated)
- Added comprehensive tests for the model family defaults module

Model family defaults are applied granularly: provider/model-specific settings
take precedence over family defaults (only undefined properties get defaults).
This commit is contained in:
Roo Code 2025-12-19 19:13:43 +00:00
parent 61903f9588
commit 0b3e7667a6
33 changed files with 565 additions and 114 deletions

View file

@ -245,6 +245,9 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
}
}
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({ format: "anthropic", modelId: id, model: info, settings: this.options })
// Build betas array for request headers

View file

@ -361,6 +361,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
}
}
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({
format: "anthropic",
modelId: id,

View file

@ -243,6 +243,8 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
? (this.options.apiModelId as ModelName)
: this.defaultProviderModelId
return { id, info: this.providerModels[id] }
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(id, this.providerModels[id])
return { id, info }
}
}

View file

@ -5,6 +5,7 @@ import type { ModelInfo } from "@roo-code/types"
import type { ApiHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { ApiStream } from "../transform/stream"
import { countTokens } from "../../utils/countTokens"
import { applyModelFamilyDefaults } from "./utils/model-family-defaults"
/**
* Base class for API providers that implements common functionality.
@ -103,4 +104,23 @@ export abstract class BaseProvider implements ApiHandler {
return countTokens(content, { useWorker: true })
}
/**
* Apply model family defaults to a ModelInfo object.
*
* This helper method allows providers to apply consistent defaults
* for recognized model families (OpenAI, Gemini, etc.) regardless of
* which provider is serving the model.
*
* Defaults are only applied when the corresponding property is not
* already explicitly set on the model info, ensuring that provider-specific
* or model-specific settings take precedence.
*
* @param modelId - The model identifier
* @param info - The original ModelInfo object
* @returns A new ModelInfo object with family defaults applied
*/
protected applyModelDefaults(modelId: string, info: ModelInfo): ModelInfo {
return applyModelFamilyDefaults(modelId, info)
}
}

View file

@ -1072,15 +1072,17 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
reasoningBudget?: number
} {
if (this.costModelConfig?.id?.trim().length > 0) {
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(this.costModelConfig.id, this.costModelConfig.info)
// Get model params for cost model config
const params = getModelParams({
format: "anthropic",
modelId: this.costModelConfig.id,
model: this.costModelConfig.info,
model: info,
settings: this.options,
defaultTemperature: BEDROCK_DEFAULT_TEMPERATURE,
})
return { ...this.costModelConfig, ...params }
return { ...this.costModelConfig, info, ...params }
}
let modelConfig = undefined
@ -1158,6 +1160,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
}
}
// Apply model family defaults for consistent behavior across providers
modelConfig.info = this.applyModelDefaults(modelConfig.id, modelConfig.info)
// Don't override maxTokens/contextWindow here; handled in getModelById (and includes user overrides)
return { ...modelConfig, ...params } as {
id: BedrockModelId | string

View file

@ -1,6 +1,6 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { type CerebrasModelId, cerebrasDefaultModelId, cerebrasModels } from "@roo-code/types"
import { type CerebrasModelId, type ModelInfo, cerebrasDefaultModelId, cerebrasModels } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
@ -38,13 +38,18 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
}
}
getModel(): { id: CerebrasModelId; info: (typeof cerebrasModels)[CerebrasModelId] } {
getModel(): { id: CerebrasModelId; info: ModelInfo } {
const modelId = this.options.apiModelId as CerebrasModelId
const validModelId = modelId && this.providerModels[modelId] ? modelId : this.defaultProviderModelId
let info: ModelInfo = { ...this.providerModels[validModelId] }
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(validModelId, info)
return {
id: validModelId,
info: this.providerModels[validModelId],
info,
}
}

View file

@ -9,6 +9,7 @@ import {
type ModelInfo,
} from "@roo-code/types"
import { type ApiHandler, ApiHandlerCreateMessageMetadata, type SingleCompletionHandler } from ".."
import { applyModelFamilyDefaults } from "./utils/model-family-defaults"
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
import { claudeCodeOAuthManager, generateUserId } from "../../integrations/claude-code/oauth"
import {
@ -275,12 +276,18 @@ export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler {
const modelId = this.options.apiModelId
if (modelId && Object.hasOwn(claudeCodeModels, modelId)) {
const id = modelId as ClaudeCodeModelId
return { id, info: { ...claudeCodeModels[id] } }
// Apply model family defaults for consistent behavior across providers
const info = applyModelFamilyDefaults(id, { ...claudeCodeModels[id] })
return { id, info }
}
// Apply model family defaults for consistent behavior across providers
const info = applyModelFamilyDefaults(claudeCodeDefaultModelId, {
...claudeCodeModels[claudeCodeDefaultModelId],
})
return {
id: claudeCodeDefaultModelId,
info: { ...claudeCodeModels[claudeCodeDefaultModelId] },
info,
}
}

View file

@ -40,7 +40,10 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion
override getModel() {
const id = this.options.deepInfraModelId ?? deepInfraDefaultModelId
const info = this.models[id] ?? deepInfraDefaultModelInfo
let info = this.models[id] ?? deepInfraDefaultModelInfo
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({
format: "openai",

View file

@ -2,6 +2,7 @@ import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import {
type ModelInfo,
deepSeekModels,
deepSeekDefaultModelId,
DEEP_SEEK_DEFAULT_TEMPERATURE,
@ -36,7 +37,13 @@ export class DeepSeekHandler extends OpenAiHandler {
override getModel() {
const id = this.options.apiModelId ?? deepSeekDefaultModelId
const info = deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId]
let info: ModelInfo = {
...(deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId]),
}
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
return { id, info, ...params }
}

View file

@ -1,6 +1,6 @@
import { OpenAiHandler } from "./openai"
import type { ApiHandlerOptions } from "../../shared/api"
import { DOUBAO_API_BASE_URL, doubaoDefaultModelId, doubaoModels } from "@roo-code/types"
import { type ModelInfo, DOUBAO_API_BASE_URL, doubaoDefaultModelId, doubaoModels } from "@roo-code/types"
import { getModelParams } from "../transform/model-params"
import { ApiStreamUsageChunk } from "../transform/stream"
@ -63,7 +63,13 @@ export class DoubaoHandler extends OpenAiHandler {
override getModel() {
const id = this.options.apiModelId ?? doubaoDefaultModelId
const info = doubaoModels[id as keyof typeof doubaoModels] || doubaoModels[doubaoDefaultModelId]
let info: ModelInfo = {
...(doubaoModels[id as keyof typeof doubaoModels] || doubaoModels[doubaoDefaultModelId]),
}
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
return { id, info, ...params }
}

View file

@ -348,6 +348,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
let id = modelId && modelId in geminiModels ? (modelId as GeminiModelId) : geminiDefaultModelId
let info: ModelInfo = geminiModels[id]
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({
format: "gemini",
modelId: id,

View file

@ -1,6 +1,8 @@
import OpenAI from "openai"
import { Anthropic } from "@anthropic-ai/sdk"
import type { ModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions, ModelRecord } from "../../shared/api"
import { ApiStream } from "../transform/stream"
import { convertToOpenAiMessages } from "../transform/openai-format"
@ -112,9 +114,11 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion
const modelId = this.options.huggingFaceModelId || "meta-llama/Llama-3.3-70B-Instruct"
// Try to get model info from cache
const modelInfo = this.modelCache?.[modelId]
let modelInfo = this.modelCache?.[modelId]
if (modelInfo) {
// Apply model family defaults for consistent behavior across providers
modelInfo = this.applyModelDefaults(modelId, modelInfo)
return {
id: modelId,
info: modelInfo,
@ -122,14 +126,19 @@ export class HuggingFaceHandler extends BaseProvider implements SingleCompletion
}
// Fallback to default values if model not found in cache
let defaultInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
}
// Apply model family defaults for consistent behavior across providers
defaultInfo = this.applyModelDefaults(modelId, defaultInfo)
return {
id: modelId,
info: {
maxTokens: 8192,
contextWindow: 131072,
supportsImages: false,
supportsPromptCache: false,
},
info: defaultInfo,
}
}
}

View file

@ -7,6 +7,7 @@ import { getCommand } from "../../utils/commands"
import { ApiStream } from "../transform/stream"
import type { ApiHandler, SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { applyModelFamilyDefaults } from "./utils/model-family-defaults"
/**
* Human Relay API processor
@ -62,18 +63,22 @@ export class HumanRelayHandler implements ApiHandler, SingleCompletionHandler {
* Get model information
*/
getModel(): { id: string; info: ModelInfo } {
const modelId = "human-relay"
// Human relay does not depend on a specific model, here is a default configuration
const baseInfo: ModelInfo = {
maxTokens: 16384,
contextWindow: 100000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Calling web-side AI model through human relay",
}
// Apply model family defaults for consistent behavior across providers
const info = applyModelFamilyDefaults(modelId, baseInfo)
return {
id: "human-relay",
info: {
maxTokens: 16384,
contextWindow: 100000,
supportsImages: true,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Calling web-side AI model through human relay",
},
id: modelId,
info,
}
}

View file

@ -1,4 +1,9 @@
import { ioIntelligenceDefaultModelId, ioIntelligenceModels, type IOIntelligenceModelId } from "@roo-code/types"
import {
type ModelInfo,
ioIntelligenceDefaultModelId,
ioIntelligenceModels,
type IOIntelligenceModelId,
} from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
@ -27,18 +32,25 @@ export class IOIntelligenceHandler extends BaseOpenAiCompatibleProvider<IOIntell
this.providerModels[modelId as IOIntelligenceModelId] ?? this.providerModels[ioIntelligenceDefaultModelId]
if (modelInfo) {
return { id: modelId as IOIntelligenceModelId, info: modelInfo }
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(modelId, modelInfo)
return { id: modelId as IOIntelligenceModelId, info }
}
// Return the requested model ID even if not found, with fallback info.
let defaultInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
}
// Apply model family defaults for consistent behavior across providers
defaultInfo = this.applyModelDefaults(modelId, defaultInfo)
return {
id: modelId as IOIntelligenceModelId,
info: {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
},
info: defaultInfo,
}
}
}

View file

@ -174,16 +174,21 @@ export class LmStudioHandler extends BaseProvider implements SingleCompletionHan
}
override getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.lmStudioModelId || ""
const models = getModelsFromCache("lmstudio")
if (models && this.options.lmStudioModelId && models[this.options.lmStudioModelId]) {
if (models && modelId && models[modelId]) {
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(modelId, models[modelId])
return {
id: this.options.lmStudioModelId,
info: models[this.options.lmStudioModelId],
id: modelId,
info,
}
} else {
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(modelId, openAiModelInfoSaneDefaults)
return {
id: this.options.lmStudioModelId || "",
info: openAiModelInfoSaneDefaults,
id: modelId,
info,
}
}
}

View file

@ -272,7 +272,9 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand
getModel() {
const modelId = this.options.apiModelId
const id = modelId && modelId in minimaxModels ? (modelId as MinimaxModelId) : minimaxDefaultModelId
const info = minimaxModels[id]
const baseInfo = minimaxModels[id]
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(id, baseInfo)
const params = getModelParams({
format: "anthropic",

View file

@ -4,6 +4,7 @@ import OpenAI from "openai"
import {
type MistralModelId,
type ModelInfo,
mistralDefaultModelId,
mistralModels,
MISTRAL_DEFAULT_TEMPERATURE,
@ -89,7 +90,7 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand
} = {
model,
messages: [{ role: "system", content: systemPrompt }, ...convertToMistralMessages(messages)],
maxTokens: maxTokens ?? info.maxTokens,
maxTokens: maxTokens ?? info.maxTokens ?? 8192,
temperature,
}
@ -187,7 +188,10 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand
override getModel() {
const id = this.options.apiModelId ?? mistralDefaultModelId
const info = mistralModels[id as MistralModelId] ?? mistralModels[mistralDefaultModelId]
let info: ModelInfo = { ...(mistralModels[id as MistralModelId] ?? mistralModels[mistralDefaultModelId]) }
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
// @TODO: Move this to the `getModelParams` function.
const maxTokens = this.options.includeMaxTokens ? info.maxTokens : undefined

View file

@ -22,7 +22,13 @@ export class MoonshotHandler extends OpenAiHandler {
override getModel() {
const id = this.options.apiModelId ?? moonshotDefaultModelId
const info = moonshotModels[id as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId]
let info: ModelInfo = {
...(moonshotModels[id as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId]),
}
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
return { id, info, ...params }
}

View file

@ -334,9 +334,12 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
override getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.ollamaModelId || ""
const baseInfo = this.models[modelId] || openAiModelInfoSaneDefaults
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(modelId, baseInfo)
return {
id: modelId,
info: this.models[modelId] || openAiModelInfoSaneDefaults,
info,
}
}

View file

@ -1220,7 +1220,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
let id =
modelId && modelId in openAiNativeModels ? (modelId as OpenAiNativeModelId) : openAiNativeDefaultModelId
const info: ModelInfo = openAiNativeModels[id]
let info: ModelInfo = openAiNativeModels[id]
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({
format: "openai",

View file

@ -299,7 +299,11 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
override getModel() {
const id = this.options.openAiModelId ?? ""
const info = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults
let info = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
return { id, info, ...params }
}

View file

@ -35,7 +35,6 @@ import { BaseProvider } from "./base-provider"
import type { ApiHandlerCreateMessageMetadata, SingleCompletionHandler } from "../index"
import { handleOpenAIError } from "./utils/openai-error-handler"
import { generateImageWithProvider, ImageGenerationResult } from "./utils/image-generation"
import { applyRouterToolPreferences } from "./utils/router-tool-preferences"
// Add custom interface for OpenRouter params.
type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & {
@ -530,8 +529,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
info = this.endpoints[this.options.openRouterSpecificProvider]
}
// Apply tool preferences for models accessed through routers (OpenAI, Gemini)
info = applyRouterToolPreferences(id, info)
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || id === "perplexity/sonar-reasoning"

View file

@ -322,7 +322,9 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan
override getModel(): { id: string; info: ModelInfo } {
const id = this.options.apiModelId ?? qwenCodeDefaultModelId
const info = qwenCodeModels[id as keyof typeof qwenCodeModels] || qwenCodeModels[qwenCodeDefaultModelId]
const baseInfo = qwenCodeModels[id as keyof typeof qwenCodeModels] || qwenCodeModels[qwenCodeDefaultModelId]
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(id, baseInfo)
return { id, info }
}

View file

@ -18,7 +18,6 @@ import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { toRequestyServiceUrl } from "../../shared/utils/requesty"
import { handleOpenAIError } from "./utils/openai-error-handler"
import { applyRouterToolPreferences } from "./utils/router-tool-preferences"
// Requesty usage includes an extra field for Anthropic use cases.
// Safely cast the prompt token details section to the appropriate structure.
@ -81,8 +80,8 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
const id = this.options.requestyModelId ?? requestyDefaultModelId
let info = this.models[id] ?? requestyDefaultModelInfo
// Apply tool preferences for models accessed through routers (OpenAI, Gemini)
info = applyRouterToolPreferences(id, info)
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({
format: "anthropic",

View file

@ -349,11 +349,13 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
const modelInfo = models[modelId]
if (modelInfo) {
return { id: modelId, info: modelInfo }
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(modelId, modelInfo)
return { id: modelId, info }
}
// Return the requested model ID even if not found, with fallback info.
const fallbackInfo = {
let fallbackInfo = {
maxTokens: 16_384,
contextWindow: 262_144,
supportsImages: false,
@ -365,6 +367,9 @@ export class RooHandler extends BaseOpenAiCompatibleProvider<string> {
isFree: false,
}
// Apply model family defaults for consistent behavior across providers
fallbackInfo = this.applyModelDefaults(modelId, fallbackInfo) as typeof fallbackInfo
return {
id: modelId,
info: fallbackInfo,

View file

@ -65,7 +65,9 @@ export abstract class RouterProvider extends BaseProvider {
// First check instance models (populated by fetchModel)
if (this.models[id]) {
return { id, info: this.models[id] }
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(id, this.models[id])
return { id, info }
}
// Fall back to global cache (synchronous disk/memory cache)
@ -74,11 +76,15 @@ export abstract class RouterProvider extends BaseProvider {
if (cachedModels?.[id]) {
// Also populate instance models for future calls
this.models = cachedModels
return { id, info: cachedModels[id] }
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(id, cachedModels[id])
return { id, info }
}
// Last resort: return default model
return { id: this.defaultModelId, info: this.defaultModelInfo }
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(this.defaultModelId, this.defaultModelInfo)
return { id: this.defaultModelId, info }
}
protected supportsTemperature(modelId: string): boolean {

View file

@ -63,7 +63,9 @@ export class UnboundHandler extends RouterProvider implements SingleCompletionHa
const requestedId = this.options.unboundModelId ?? unboundDefaultModelId
const modelExists = this.models[requestedId]
const id = modelExists ? requestedId : unboundDefaultModelId
const info = modelExists ? this.models[requestedId] : unboundDefaultModelInfo
const baseInfo = modelExists ? this.models[requestedId] : unboundDefaultModelInfo
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(id, baseInfo)
const params = getModelParams({
format: "openai",

View file

@ -0,0 +1,224 @@
import { ModelInfo } from "@roo-code/types"
import { applyModelFamilyDefaults, MODEL_FAMILY_REGISTRY } from "../model-family-defaults"
describe("model-family-defaults", () => {
describe("MODEL_FAMILY_REGISTRY", () => {
it("should have Gemini 3 pattern as first entry (most specific)", () => {
expect(MODEL_FAMILY_REGISTRY[0].pattern.toString()).toMatch(/gemini-3|gemini\/gemini-3/i)
})
it("should have general Gemini pattern after Gemini 3", () => {
const geminiIndex = MODEL_FAMILY_REGISTRY.findIndex(
(config) =>
config.pattern.toString().includes("gemini") && !config.pattern.toString().includes("gemini-3"),
)
expect(geminiIndex).toBeGreaterThan(0)
})
it("should have OpenAI/GPT pattern", () => {
const openaiConfig = MODEL_FAMILY_REGISTRY.find((config) => config.pattern.toString().includes("gpt"))
expect(openaiConfig).toBeDefined()
expect(openaiConfig!.defaults.includedTools).toContain("apply_patch")
expect(openaiConfig!.defaults.excludedTools).toContain("apply_diff")
})
})
describe("applyModelFamilyDefaults", () => {
const baseInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: false,
}
describe("Gemini 3 models", () => {
it("should apply Gemini 3 defaults for gemini-3 model", () => {
const result = applyModelFamilyDefaults("gemini-3-flash", baseInfo)
expect(result.includedTools).toEqual(["write_file", "edit_file"])
expect(result.excludedTools).toEqual(["apply_diff"])
expect(result.defaultTemperature).toBe(1)
})
it("should apply Gemini 3 defaults for google/gemini-3 model", () => {
const result = applyModelFamilyDefaults("google/gemini-3-pro", baseInfo)
expect(result.includedTools).toEqual(["write_file", "edit_file"])
expect(result.excludedTools).toEqual(["apply_diff"])
expect(result.defaultTemperature).toBe(1)
})
it("should NOT override explicit includedTools", () => {
const infoWithTools: ModelInfo = {
...baseInfo,
includedTools: ["custom_tool"],
}
const result = applyModelFamilyDefaults("gemini-3-flash", infoWithTools)
expect(result.includedTools).toEqual(["custom_tool"])
expect(result.excludedTools).toEqual(["apply_diff"]) // This wasn't set, so default applies
})
it("should NOT override explicit excludedTools", () => {
const infoWithTools: ModelInfo = {
...baseInfo,
excludedTools: ["other_tool"],
}
const result = applyModelFamilyDefaults("gemini-3-flash", infoWithTools)
expect(result.excludedTools).toEqual(["other_tool"])
expect(result.includedTools).toEqual(["write_file", "edit_file"]) // This wasn't set, so default applies
})
it("should NOT override explicit defaultTemperature", () => {
const infoWithTemp: ModelInfo = {
...baseInfo,
defaultTemperature: 0.5,
}
const result = applyModelFamilyDefaults("gemini-3-flash", infoWithTemp)
expect(result.defaultTemperature).toBe(0.5)
})
})
describe("General Gemini models", () => {
it("should apply Gemini defaults for gemini model", () => {
const result = applyModelFamilyDefaults("gemini-1.5-pro", baseInfo)
expect(result.includedTools).toEqual(["write_file", "edit_file"])
expect(result.excludedTools).toEqual(["apply_diff"])
expect(result.defaultTemperature).toBeUndefined() // General Gemini doesn't set temperature
})
it("should apply Gemini defaults for google/gemini model", () => {
const result = applyModelFamilyDefaults("google/gemini-1.5-flash", baseInfo)
expect(result.includedTools).toEqual(["write_file", "edit_file"])
expect(result.excludedTools).toEqual(["apply_diff"])
})
it("should apply Gemini defaults for models with gemini in the name", () => {
const result = applyModelFamilyDefaults("openrouter/google/gemini-pro", baseInfo)
expect(result.includedTools).toEqual(["write_file", "edit_file"])
expect(result.excludedTools).toEqual(["apply_diff"])
})
})
describe("OpenAI/GPT models", () => {
it("should apply OpenAI defaults for gpt-4 model", () => {
const result = applyModelFamilyDefaults("gpt-4-turbo", baseInfo)
expect(result.includedTools).toEqual(["apply_patch"])
expect(result.excludedTools).toEqual(["apply_diff", "write_to_file"])
})
it("should apply OpenAI defaults for openai/ prefixed model", () => {
const result = applyModelFamilyDefaults("openai/gpt-4o", baseInfo)
expect(result.includedTools).toEqual(["apply_patch"])
expect(result.excludedTools).toEqual(["apply_diff", "write_to_file"])
})
it("should apply OpenAI defaults for o1 model", () => {
const result = applyModelFamilyDefaults("o1-preview", baseInfo)
expect(result.includedTools).toEqual(["apply_patch"])
expect(result.excludedTools).toEqual(["apply_diff", "write_to_file"])
})
it("should apply OpenAI defaults for o3-mini model", () => {
const result = applyModelFamilyDefaults("o3-mini", baseInfo)
expect(result.includedTools).toEqual(["apply_patch"])
expect(result.excludedTools).toEqual(["apply_diff", "write_to_file"])
})
it("should apply OpenAI defaults for o4 model", () => {
const result = applyModelFamilyDefaults("o4", baseInfo)
expect(result.includedTools).toEqual(["apply_patch"])
expect(result.excludedTools).toEqual(["apply_diff", "write_to_file"])
})
})
describe("Non-matching models", () => {
it("should return unchanged info for non-matching model", () => {
const result = applyModelFamilyDefaults("claude-3-opus", baseInfo)
expect(result).toEqual(baseInfo)
})
it("should return unchanged info for anthropic models", () => {
const result = applyModelFamilyDefaults("anthropic/claude-3.5-sonnet", baseInfo)
expect(result).toEqual(baseInfo)
})
it("should return unchanged info for deepseek models", () => {
const result = applyModelFamilyDefaults("deepseek-r1", baseInfo)
expect(result).toEqual(baseInfo)
})
})
describe("Preserving existing properties", () => {
it("should preserve all original info properties", () => {
const fullInfo: ModelInfo = {
maxTokens: 16384,
contextWindow: 256000,
supportsImages: true,
supportsPromptCache: true,
supportsNativeTools: true,
inputPrice: 0.01,
outputPrice: 0.03,
cacheReadsPrice: 0.005,
cacheWritesPrice: 0.015,
description: "Test model",
}
const result = applyModelFamilyDefaults("gemini-3-flash", fullInfo)
// Original properties should be preserved
expect(result.maxTokens).toBe(16384)
expect(result.contextWindow).toBe(256000)
expect(result.supportsImages).toBe(true)
expect(result.supportsPromptCache).toBe(true)
expect(result.supportsNativeTools).toBe(true)
expect(result.inputPrice).toBe(0.01)
expect(result.outputPrice).toBe(0.03)
expect(result.cacheReadsPrice).toBe(0.005)
expect(result.cacheWritesPrice).toBe(0.015)
expect(result.description).toBe("Test model")
// Family defaults should be applied
expect(result.includedTools).toEqual(["write_file", "edit_file"])
expect(result.excludedTools).toEqual(["apply_diff"])
expect(result.defaultTemperature).toBe(1)
})
it("should not mutate original info object", () => {
const originalInfo: ModelInfo = {
maxTokens: 8192,
contextWindow: 128000,
supportsImages: false,
supportsPromptCache: false,
}
const originalCopy = { ...originalInfo }
applyModelFamilyDefaults("gemini-3-flash", originalInfo)
expect(originalInfo).toEqual(originalCopy)
})
})
describe("First-match-wins behavior", () => {
it("should use Gemini 3 defaults for gemini-3 (not general Gemini)", () => {
const result = applyModelFamilyDefaults("gemini-3-flash", baseInfo)
// Gemini 3 has defaultTemperature: 1, general Gemini doesn't
expect(result.defaultTemperature).toBe(1)
})
it("should use general Gemini defaults for gemini-1.5 (not Gemini 3)", () => {
const result = applyModelFamilyDefaults("gemini-1.5-pro", baseInfo)
// General Gemini doesn't have defaultTemperature, so it should be undefined
expect(result.defaultTemperature).toBeUndefined()
})
})
describe("Case insensitivity", () => {
it("should match Gemini case-insensitively", () => {
const result = applyModelFamilyDefaults("GEMINI-1.5-PRO", baseInfo)
expect(result.includedTools).toEqual(["write_file", "edit_file"])
})
it("should match GPT case-insensitively", () => {
const result = applyModelFamilyDefaults("GPT-4-TURBO", baseInfo)
expect(result.includedTools).toEqual(["apply_patch"])
})
})
})
})

View file

@ -0,0 +1,122 @@
import type { ModelInfo } from "@roo-code/types"
/**
* Model family default configuration.
* Each entry defines a pattern to match against model IDs and default ModelInfo properties
* to apply when the pattern matches.
*/
interface ModelFamilyConfig {
/**
* Regular expression pattern to match against model IDs.
* More specific patterns should come first in the registry.
*/
pattern: RegExp
/**
* Description of this model family for documentation purposes.
*/
description: string
/**
* Default ModelInfo properties to apply when this pattern matches.
* These will only be applied if the corresponding property is not already set.
*/
defaults: Partial<ModelInfo>
}
/**
* Registry of model family configurations.
*
* IMPORTANT: Order matters! Patterns are matched first-match-wins,
* so more specific patterns should come before more general ones.
*
* For example, "gemini-3" should come before "gemini" to ensure
* Gemini 3 models get their specific defaults before falling back
* to general Gemini defaults.
*/
export const MODEL_FAMILY_REGISTRY: ModelFamilyConfig[] = [
// Gemini 3 models (most specific - must come before general gemini)
{
pattern: /gemini-3|gemini\/gemini-3/i,
description: "Google Gemini 3 models with enhanced tool support and temperature defaults",
defaults: {
defaultTemperature: 1,
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
},
},
// All Gemini models (general fallback for non-Gemini-3)
{
pattern: /gemini|google\/gemini/i,
description: "Google Gemini models with file-based tool preferences",
defaults: {
includedTools: ["write_file", "edit_file"],
excludedTools: ["apply_diff"],
},
},
// OpenAI GPT models (includes models with "gpt" or "openai" in the ID)
{
pattern: /gpt|openai\/|^o[134]-|^o[134]$/i,
description: "OpenAI GPT and O-series models with apply_patch preference",
defaults: {
includedTools: ["apply_patch"],
excludedTools: ["apply_diff", "write_to_file"],
},
},
]
/**
* Apply model family defaults to a ModelInfo object.
*
* This function matches the model ID against patterns in the MODEL_FAMILY_REGISTRY
* and applies the first matching family's defaults. Defaults are only applied for
* properties that are not already explicitly set on the input ModelInfo.
*
* @param modelId - The model identifier (e.g., "openai/gpt-4", "google/gemini-2.5-pro")
* @param info - The original ModelInfo object
* @returns A new ModelInfo object with family defaults applied (if any match)
*
* @example
* ```typescript
* // Model accessed through OpenRouter
* const info = applyModelFamilyDefaults("openai/gpt-4o", { maxTokens: 16384, contextWindow: 128000 })
* // Result: { maxTokens: 16384, contextWindow: 128000, includedTools: ["apply_patch"], excludedTools: ["apply_diff", "write_to_file"] }
*
* // Model with explicitly set tools (not overridden)
* const info2 = applyModelFamilyDefaults("openai/gpt-4o", { includedTools: ["custom_tool"], contextWindow: 128000 })
* // Result: { includedTools: ["custom_tool"], contextWindow: 128000, excludedTools: ["apply_diff", "write_to_file"] }
* ```
*/
export function applyModelFamilyDefaults(modelId: string, info: ModelInfo): ModelInfo {
// Find the first matching family configuration
const matchingFamily = MODEL_FAMILY_REGISTRY.find((family) => family.pattern.test(modelId))
// If no match found, return the original info unchanged
if (!matchingFamily) {
return info
}
// Apply defaults only for properties that are not already set
const result = { ...info }
const defaults = matchingFamily.defaults
// Apply defaultTemperature if not already set
if (defaults.defaultTemperature !== undefined && result.defaultTemperature === undefined) {
result.defaultTemperature = defaults.defaultTemperature
}
// Apply includedTools if not already set
// Note: We check for undefined specifically, as an empty array is a valid explicit value
if (defaults.includedTools !== undefined && result.includedTools === undefined) {
result.includedTools = [...defaults.includedTools]
}
// Apply excludedTools if not already set
if (defaults.excludedTools !== undefined && result.excludedTools === undefined) {
result.excludedTools = [...defaults.excludedTools]
}
return result
}

View file

@ -1,41 +0,0 @@
import type { ModelInfo } from "@roo-code/types"
/**
* Apply tool preferences for models accessed through dynamic routers (OpenRouter, Requesty).
*
* Different model families perform better with specific tools:
* - OpenAI models: Better results with apply_patch instead of apply_diff/write_to_file
* - Gemini models: Higher quality results with write_file and edit_file
*
* This function modifies the model info to apply these preferences consistently
* across all dynamic router providers.
*
* @param modelId The model identifier (e.g., "openai/gpt-4", "google/gemini-2.5-pro")
* @param info The original model info object
* @returns A new model info object with tool preferences applied
*/
export function applyRouterToolPreferences(modelId: string, info: ModelInfo): ModelInfo {
let result = info
// For OpenAI models via routers, exclude write_to_file and apply_diff, and include apply_patch
// This matches the behavior of the native OpenAI provider
if (modelId.includes("openai")) {
result = {
...result,
excludedTools: [...new Set([...(result.excludedTools || []), "apply_diff", "write_to_file"])],
includedTools: [...new Set([...(result.includedTools || []), "apply_patch"])],
}
}
// For Gemini models via routers, include write_file and edit_file
// This matches the behavior of the native Gemini provider
if (modelId.includes("gemini")) {
result = {
...result,
excludedTools: [...new Set([...(result.excludedTools || []), "apply_diff"])],
includedTools: [...new Set([...(result.includedTools || []), "write_file", "edit_file"])],
}
}
return result
}

View file

@ -15,7 +15,11 @@ export class VertexHandler extends GeminiHandler implements SingleCompletionHand
override getModel() {
const modelId = this.options.apiModelId
let id = modelId && modelId in vertexModels ? (modelId as VertexModelId) : vertexDefaultModelId
const info: ModelInfo = vertexModels[id]
let info: ModelInfo = vertexModels[id]
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({ format: "gemini", modelId: id, model: info, settings: this.options })
// The `:thinking` suffix indicates that the model is a "Hybrid"

View file

@ -537,7 +537,7 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
const modelId = this.client.id || modelParts.join(SELECTOR_SEPARATOR)
// Build model info with conservative defaults for missing values
const modelInfo: ModelInfo = {
const baseInfo: ModelInfo = {
maxTokens: -1, // Unlimited tokens by default
contextWindow:
typeof this.client.maxInputTokens === "number"
@ -552,7 +552,10 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
description: `VSCode Language Model: ${modelId}`,
}
return { id: modelId, info: modelInfo }
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(modelId, baseInfo)
return { id: modelId, info }
}
// Fallback when no client is available
@ -562,14 +565,19 @@ export class VsCodeLmHandler extends BaseProvider implements SingleCompletionHan
console.debug("Roo Code <Language Model API>: No client available, using fallback model info")
const baseInfo: ModelInfo = {
...openAiModelInfoSaneDefaults,
supportsNativeTools: true, // VSCode Language Model API supports native tool calling
defaultToolProtocol: "native", // Use native tool protocol by default
description: `VSCode Language Model (Fallback): ${fallbackId}`,
}
// Apply model family defaults for consistent behavior across providers
const info = this.applyModelDefaults(fallbackId, baseInfo)
return {
id: fallbackId,
info: {
...openAiModelInfoSaneDefaults,
supportsNativeTools: true, // VSCode Language Model API supports native tool calling
defaultToolProtocol: "native", // Use native tool protocol by default
description: `VSCode Language Model (Fallback): ${fallbackId}`,
},
info,
}
}

View file

@ -1,7 +1,7 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { type XAIModelId, xaiDefaultModelId, xaiModels, ApiProviderError } from "@roo-code/types"
import { type XAIModelId, type ModelInfo, xaiDefaultModelId, xaiModels, ApiProviderError } from "@roo-code/types"
import { TelemetryService } from "@roo-code/telemetry"
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
@ -42,7 +42,11 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
? (this.options.apiModelId as XAIModelId)
: xaiDefaultModelId
const info = xaiModels[id]
let info: ModelInfo = { ...xaiModels[id] }
// Apply model family defaults for consistent behavior across providers
info = this.applyModelDefaults(id, info)
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
return { id, info, ...params }
}