diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index cbfae08f41e..de3e37d1167 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -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 diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index e04301b678c..72bd87d267d 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -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, diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index 92b9558c451..69e727461e6 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -243,6 +243,8 @@ export abstract class BaseOpenAiCompatibleProvider ? (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 } } } diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index 84c8cf6fe97..dd63a7d174b 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -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) + } } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 761500750d0..88b6f5c6ac4 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -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 diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts index 99e7c4cc3d4..8dff941a0d5 100644 --- a/src/api/providers/cerebras.ts +++ b/src/api/providers/cerebras.ts @@ -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, } } diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index cdd1cb3beb7..90dd5f6594c 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -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, } } diff --git a/src/api/providers/deepinfra.ts b/src/api/providers/deepinfra.ts index 4dfad2689a9..28be7106614 100644 --- a/src/api/providers/deepinfra.ts +++ b/src/api/providers/deepinfra.ts @@ -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", diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 4e5aef23a53..c953ae9329c 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -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 } } diff --git a/src/api/providers/doubao.ts b/src/api/providers/doubao.ts index a1337ed558a..6d970352461 100644 --- a/src/api/providers/doubao.ts +++ b/src/api/providers/doubao.ts @@ -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 } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 2c7b10f2057..35a795cdb0e 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -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, diff --git a/src/api/providers/huggingface.ts b/src/api/providers/huggingface.ts index 7b62046b99e..4fb85bf836e 100644 --- a/src/api/providers/huggingface.ts +++ b/src/api/providers/huggingface.ts @@ -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, } } } diff --git a/src/api/providers/human-relay.ts b/src/api/providers/human-relay.ts index 54446bd3625..e3f57da0e1d 100644 --- a/src/api/providers/human-relay.ts +++ b/src/api/providers/human-relay.ts @@ -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, } } diff --git a/src/api/providers/io-intelligence.ts b/src/api/providers/io-intelligence.ts index ef1c60a6a2c..ed222f3c215 100644 --- a/src/api/providers/io-intelligence.ts +++ b/src/api/providers/io-intelligence.ts @@ -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 { 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 { isFree: false, } + // Apply model family defaults for consistent behavior across providers + fallbackInfo = this.applyModelDefaults(modelId, fallbackInfo) as typeof fallbackInfo + return { id: modelId, info: fallbackInfo, diff --git a/src/api/providers/router-provider.ts b/src/api/providers/router-provider.ts index e43f49aa2c2..20fa7033217 100644 --- a/src/api/providers/router-provider.ts +++ b/src/api/providers/router-provider.ts @@ -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 { diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 667dcc60839..3aa9255657c 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -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", diff --git a/src/api/providers/utils/__tests__/model-family-defaults.spec.ts b/src/api/providers/utils/__tests__/model-family-defaults.spec.ts new file mode 100644 index 00000000000..c623d240abd --- /dev/null +++ b/src/api/providers/utils/__tests__/model-family-defaults.spec.ts @@ -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"]) + }) + }) + }) +}) diff --git a/src/api/providers/utils/model-family-defaults.ts b/src/api/providers/utils/model-family-defaults.ts new file mode 100644 index 00000000000..7c33891ee45 --- /dev/null +++ b/src/api/providers/utils/model-family-defaults.ts @@ -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 +} + +/** + * 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 +} diff --git a/src/api/providers/utils/router-tool-preferences.ts b/src/api/providers/utils/router-tool-preferences.ts deleted file mode 100644 index bb5ece3b96b..00000000000 --- a/src/api/providers/utils/router-tool-preferences.ts +++ /dev/null @@ -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 -} diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 2c077d97b7e..b3b97860a4d 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -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" diff --git a/src/api/providers/vscode-lm.ts b/src/api/providers/vscode-lm.ts index ed244ba97d7..6be5c56b480 100644 --- a/src/api/providers/vscode-lm.ts +++ b/src/api/providers/vscode-lm.ts @@ -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 : 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, } } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index a1377a1317a..2ff3695c956 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -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 } }