feat(roo): add versioned settings support with minPluginVersion gating (#9934)

This commit is contained in:
Hannes Rudolph 2025-12-09 13:54:15 -07:00 committed by GitHub
parent f89a6bef30
commit 83787a76ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 608 additions and 3 deletions

View file

@ -41,7 +41,14 @@ export const RooModelSchema = z.object({
default_temperature: z.number().optional(),
// Dynamic settings that map directly to ModelInfo properties
// Allows the API to configure model-specific defaults like includedTools, excludedTools, reasoningEffort, etc.
// These are always direct values (e.g., includedTools: ['search_replace']) for backward compatibility with old clients.
settings: z.record(z.string(), z.unknown()).optional(),
// Versioned settings keyed by version number (e.g., '3.36.4').
// Each version key maps to a settings object that is used when plugin version >= that version.
// New clients find the highest version key <= current version and use those settings.
// Old clients ignore this field and use plain values from `settings`.
// Example: { '3.36.4': { includedTools: ['search_replace'] }, '3.35.0': { ... } }
versionedSettings: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
})
export const RooModelsResponseSchema = z.object({

18
pnpm-lock.yaml generated
View file

@ -795,6 +795,9 @@ importers:
say:
specifier: ^0.16.0
version: 0.16.0
semver-compare:
specifier: ^1.0.0
version: 1.0.0
serialize-error:
specifier: ^12.0.0
version: 12.0.0
@ -898,6 +901,9 @@ importers:
'@types/ps-tree':
specifier: ^1.1.6
version: 1.1.6
'@types/semver-compare':
specifier: ^1.0.3
version: 1.0.3
'@types/shell-quote':
specifier: ^1.7.5
version: 1.7.5
@ -4160,6 +4166,9 @@ packages:
'@types/retry@0.12.5':
resolution: {integrity: sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==}
'@types/semver-compare@1.0.3':
resolution: {integrity: sha512-mVZkB2QjXmZhh+MrtwMlJ8BqUnmbiSkpd88uOWskfwB8yitBT0tBRAKt+41VRgZD9zr9Sc+Xs02qGgvzd1Rq/Q==}
'@types/shell-quote@1.7.5':
resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==}
@ -8909,6 +8918,9 @@ packages:
seed-random@2.2.0:
resolution: {integrity: sha512-34EQV6AAHQGhoc0tn/96a9Fsi6v2xdqe/dMUwljGRaFOzR3EgRmECvD0O8vi8X+/uQ50LGHfkNu/Eue5TPKZkQ==}
semver-compare@1.0.0:
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
hasBin: true
@ -13898,6 +13910,8 @@ snapshots:
'@types/retry@0.12.5': {}
'@types/semver-compare@1.0.3': {}
'@types/shell-quote@1.7.5': {}
'@types/stack-utils@2.0.3': {}
@ -14117,7 +14131,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0)
'@vitest/utils@3.2.4':
dependencies:
@ -19479,6 +19493,8 @@ snapshots:
seed-random@2.2.0: {}
semver-compare@1.0.0: {}
semver@5.7.2: {}
semver@6.3.1: {}

View file

@ -801,4 +801,179 @@ describe("getRooModels", () => {
expect(model.anotherSetting).toBe(42)
expect(model.nestedConfig).toEqual({ key: "value" })
})
it("should apply versioned settings when version matches", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/versioned-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model with Versioned Settings",
description: "Model with versioned settings",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["tool-use"],
pricing: {
input: "0.0001",
output: "0.0002",
},
// Plain settings for backward compatibility with old clients
settings: {
includedTools: ["apply_patch"],
excludedTools: ["write_to_file"],
},
// Versioned settings keyed by version number (low version - always met)
versionedSettings: {
"1.0.0": {
includedTools: ["apply_patch", "search_replace"],
excludedTools: ["apply_diff", "write_to_file"],
},
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
// Versioned settings should be used instead of plain settings
expect(models["test/versioned-model"].includedTools).toEqual(["apply_patch", "search_replace"])
expect(models["test/versioned-model"].excludedTools).toEqual(["apply_diff", "write_to_file"])
})
it("should use plain settings when no versioned settings version matches", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/old-version-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model for Old Version",
description: "Model with versioned settings for newer version",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: ["tool-use"],
pricing: {
input: "0.0001",
output: "0.0002",
},
settings: {
includedTools: ["apply_patch"],
},
// Versioned settings keyed by very high version - never met
versionedSettings: {
"99.0.0": {
includedTools: ["apply_patch", "search_replace"],
},
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
// Should use plain settings since no versioned settings match current version
expect(models["test/old-version-model"].includedTools).toEqual(["apply_patch"])
})
it("should handle model with only versionedSettings and no plain settings", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/versioned-only-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model with Only Versioned Settings",
description: "Model with only versioned settings",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: [],
pricing: {
input: "0.0001",
output: "0.0002",
},
// No plain settings, only versionedSettings keyed by version
versionedSettings: {
"1.0.0": {
customFeature: true,
},
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
const model = models["test/versioned-only-model"] as Record<string, unknown>
expect(model.customFeature).toBe(true)
})
it("should select highest matching version from versionedSettings", async () => {
const mockResponse = {
object: "list",
data: [
{
id: "test/multi-version-model",
object: "model",
created: 1234567890,
owned_by: "test",
name: "Model with Multiple Versions",
description: "Model with multiple version settings",
context_window: 128000,
max_tokens: 8192,
type: "language",
tags: [],
pricing: {
input: "0.0001",
output: "0.0002",
},
settings: {
feature: "default",
},
// Multiple version keys - should use highest one <= current version
versionedSettings: {
"99.0.0": { feature: "future" },
"3.0.0": { feature: "current" },
"2.0.0": { feature: "old" },
"1.0.0": { feature: "very_old" },
},
},
],
}
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockResponse,
})
const models = await getRooModels(baseUrl, apiKey)
const model = models["test/multi-version-model"] as Record<string, unknown>
// Should use 3.0.0 version settings (highest that's <= current plugin version)
expect(model.feature).toBe("current")
})
})

View file

@ -0,0 +1,263 @@
import {
compareSemver,
meetsMinimumVersion,
findHighestMatchingVersion,
resolveVersionedSettings,
type VersionedSettings,
} from "../versionedSettings"
describe("versionedSettings", () => {
describe("compareSemver", () => {
it("should return 0 for equal versions", () => {
expect(compareSemver("1.0.0", "1.0.0")).toBe(0)
expect(compareSemver("3.36.4", "3.36.4")).toBe(0)
expect(compareSemver("0.0.1", "0.0.1")).toBe(0)
})
it("should return positive when first version is greater", () => {
expect(compareSemver("2.0.0", "1.0.0")).toBeGreaterThan(0)
expect(compareSemver("1.1.0", "1.0.0")).toBeGreaterThan(0)
expect(compareSemver("1.0.1", "1.0.0")).toBeGreaterThan(0)
expect(compareSemver("3.36.5", "3.36.4")).toBeGreaterThan(0)
expect(compareSemver("3.37.0", "3.36.4")).toBeGreaterThan(0)
expect(compareSemver("4.0.0", "3.36.4")).toBeGreaterThan(0)
})
it("should return negative when first version is smaller", () => {
expect(compareSemver("1.0.0", "2.0.0")).toBeLessThan(0)
expect(compareSemver("1.0.0", "1.1.0")).toBeLessThan(0)
expect(compareSemver("1.0.0", "1.0.1")).toBeLessThan(0)
expect(compareSemver("3.36.3", "3.36.4")).toBeLessThan(0)
expect(compareSemver("3.35.0", "3.36.4")).toBeLessThan(0)
expect(compareSemver("2.0.0", "3.36.4")).toBeLessThan(0)
})
it("should handle pre-release versions by ignoring pre-release suffix", () => {
expect(compareSemver("3.36.4-beta.1", "3.36.4")).toBe(0)
expect(compareSemver("3.36.4-rc.2", "3.36.4")).toBe(0)
expect(compareSemver("3.36.5-alpha", "3.36.4")).toBeGreaterThan(0)
expect(compareSemver("3.36.3-beta", "3.36.4")).toBeLessThan(0)
})
it("should handle edge cases", () => {
expect(compareSemver("0.0.0", "0.0.0")).toBe(0)
expect(compareSemver("10.20.30", "10.20.30")).toBe(0)
expect(compareSemver("10.0.0", "9.99.99")).toBeGreaterThan(0)
})
})
describe("meetsMinimumVersion", () => {
it("should return true when current version equals minimum", () => {
expect(meetsMinimumVersion("3.36.4", "3.36.4")).toBe(true)
})
it("should return true when current version exceeds minimum", () => {
expect(meetsMinimumVersion("3.36.4", "3.36.5")).toBe(true)
expect(meetsMinimumVersion("3.36.4", "3.37.0")).toBe(true)
expect(meetsMinimumVersion("3.36.4", "4.0.0")).toBe(true)
})
it("should return false when current version is below minimum", () => {
expect(meetsMinimumVersion("3.36.4", "3.36.3")).toBe(false)
expect(meetsMinimumVersion("3.36.4", "3.35.0")).toBe(false)
expect(meetsMinimumVersion("3.36.4", "2.0.0")).toBe(false)
})
})
describe("findHighestMatchingVersion", () => {
it("should return undefined when no versions match", () => {
const versionedSettings: VersionedSettings = {
"4.0.0": { includedTools: ["apply_diff"] },
"5.0.0": { includedTools: ["apply_diff", "search_replace"] },
}
const result = findHighestMatchingVersion(versionedSettings, "3.36.4")
expect(result).toBeUndefined()
})
it("should return the exact version when it matches", () => {
const versionedSettings: VersionedSettings = {
"3.36.4": { includedTools: ["apply_diff"] },
"3.35.0": { includedTools: ["search_replace"] },
}
const result = findHighestMatchingVersion(versionedSettings, "3.36.4")
expect(result).toBe("3.36.4")
})
it("should return the highest version that is <= current version", () => {
const versionedSettings: VersionedSettings = {
"3.37.0": { includedTools: ["future_tool"] },
"3.36.4": { includedTools: ["apply_diff"] },
"3.35.0": { includedTools: ["search_replace"] },
"3.34.0": { includedTools: ["basic_tool"] },
}
// Current version is 3.36.5, should match 3.36.4 (highest <= 3.36.5)
const result = findHighestMatchingVersion(versionedSettings, "3.36.5")
expect(result).toBe("3.36.4")
})
it("should handle single version", () => {
const versionedSettings: VersionedSettings = {
"3.35.0": { includedTools: ["search_replace"] },
}
expect(findHighestMatchingVersion(versionedSettings, "3.36.4")).toBe("3.35.0")
expect(findHighestMatchingVersion(versionedSettings, "3.34.0")).toBeUndefined()
})
it("should handle empty versionedSettings", () => {
const versionedSettings: VersionedSettings = {}
const result = findHighestMatchingVersion(versionedSettings, "3.36.4")
expect(result).toBeUndefined()
})
})
describe("resolveVersionedSettings", () => {
const currentVersion = "3.36.4"
it("should return settings for exact version match", () => {
const versionedSettings: VersionedSettings = {
"3.36.4": {
includedTools: ["search_replace"],
excludedTools: ["apply_diff"],
},
}
const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
expect(resolved).toEqual({
includedTools: ["search_replace"],
excludedTools: ["apply_diff"],
})
})
it("should return settings for highest matching version", () => {
const versionedSettings: VersionedSettings = {
"4.0.0": {
includedTools: ["future_tool"],
},
"3.36.0": {
includedTools: ["search_replace"],
excludedTools: ["apply_diff"],
},
"3.35.0": {
includedTools: ["old_tool"],
},
}
const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
expect(resolved).toEqual({
includedTools: ["search_replace"],
excludedTools: ["apply_diff"],
})
})
it("should return empty object when no versions match", () => {
const versionedSettings: VersionedSettings = {
"4.0.0": {
includedTools: ["future_tool"],
},
"3.37.0": {
includedTools: ["newer_tool"],
},
}
const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
expect(resolved).toEqual({})
})
it("should handle empty versionedSettings", () => {
const resolved = resolveVersionedSettings({}, currentVersion)
expect(resolved).toEqual({})
})
it("should handle versioned boolean values", () => {
const versionedSettings: VersionedSettings = {
"3.36.0": {
supportsNativeTools: true,
},
}
const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
expect(resolved).toEqual({
supportsNativeTools: true,
})
})
it("should handle versioned null values", () => {
const versionedSettings: VersionedSettings = {
"3.36.0": {
defaultTemperature: null,
},
}
const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
expect(resolved).toEqual({
defaultTemperature: null,
})
})
it("should handle versioned nested objects", () => {
const versionedSettings: VersionedSettings = {
"3.36.0": {
complexSetting: { nested: { deeply: true } },
},
}
const resolved = resolveVersionedSettings(versionedSettings, currentVersion)
expect(resolved).toEqual({
complexSetting: { nested: { deeply: true } },
})
})
it("should use all settings from the matching version", () => {
const versionedSettings: VersionedSettings = {
"3.36.4": {
includedTools: ["search_replace", "apply_diff"],
excludedTools: ["write_to_file"],
supportsReasoningEffort: true,
description: "Updated model",
},
"3.35.0": {
includedTools: ["search_replace"],
description: "Old model",
},
}
const resolved = resolveVersionedSettings(versionedSettings, "3.36.4")
expect(resolved).toEqual({
includedTools: ["search_replace", "apply_diff"],
excludedTools: ["write_to_file"],
supportsReasoningEffort: true,
description: "Updated model",
})
})
it("should handle multiple versions and select correct one", () => {
const versionedSettings: VersionedSettings = {
"3.38.0": { feature: "very_new" },
"3.37.0": { feature: "new" },
"3.36.0": { feature: "current" },
"3.35.0": { feature: "old" },
"3.34.0": { feature: "very_old" },
}
// Test different current versions
expect(resolveVersionedSettings(versionedSettings, "3.40.0")).toEqual({ feature: "very_new" })
expect(resolveVersionedSettings(versionedSettings, "3.37.5")).toEqual({ feature: "new" })
expect(resolveVersionedSettings(versionedSettings, "3.36.5")).toEqual({ feature: "current" })
expect(resolveVersionedSettings(versionedSettings, "3.35.5")).toEqual({ feature: "old" })
expect(resolveVersionedSettings(versionedSettings, "3.34.5")).toEqual({ feature: "very_old" })
expect(resolveVersionedSettings(versionedSettings, "3.33.0")).toEqual({})
})
})
})

View file

@ -4,6 +4,7 @@ import type { ModelRecord } from "../../../shared/api"
import { parseApiPrice } from "../../../shared/cost"
import { DEFAULT_HEADERS } from "../constants"
import { resolveVersionedSettings, type VersionedSettings } from "./versionedSettings"
/**
* Fetches available models from the Roo Code Cloud provider
@ -128,9 +129,37 @@ export async function getRooModels(baseUrl: string, apiKey?: string): Promise<Mo
// Apply API-provided settings on top of base model info
// Settings allow the proxy to dynamically configure model-specific options
// like includedTools, excludedTools, reasoningEffort, etc.
const apiSettings = model.settings as Partial<ModelInfo> | undefined
//
// Two fields are used for backward compatibility:
// - `settings`: Plain values that work with all client versions (e.g., { includedTools: ['search_replace'] })
// - `versionedSettings`: Version-keyed settings (e.g., { '3.36.4': { includedTools: ['search_replace'] } })
//
// New clients check versionedSettings first - if a matching version is found, those settings are used.
// Otherwise, falls back to plain `settings`. Old clients only see `settings`.
const apiSettings = model.settings as Record<string, unknown> | undefined
const apiVersionedSettings = model.versionedSettings as VersionedSettings | undefined
models[modelId] = apiSettings ? { ...baseModelInfo, ...apiSettings } : baseModelInfo
// Start with base model info
let modelInfo: ModelInfo = { ...baseModelInfo }
// Try to resolve versioned settings first (finds highest version <= current plugin version)
// If versioned settings match, use them exclusively (they contain all necessary settings)
// Otherwise fall back to plain settings for backward compatibility
if (apiVersionedSettings) {
const resolvedVersionedSettings = resolveVersionedSettings<Partial<ModelInfo>>(apiVersionedSettings)
if (Object.keys(resolvedVersionedSettings).length > 0) {
// Versioned settings found - use them exclusively
modelInfo = { ...modelInfo, ...resolvedVersionedSettings }
} else if (apiSettings) {
// No matching versioned settings - fall back to plain settings
modelInfo = { ...modelInfo, ...(apiSettings as Partial<ModelInfo>) }
}
} else if (apiSettings) {
// No versioned settings at all - use plain settings
modelInfo = { ...modelInfo, ...(apiSettings as Partial<ModelInfo>) }
}
models[modelId] = modelInfo
}
return models

View file

@ -0,0 +1,113 @@
import cmp from "semver-compare"
import { Package } from "../../../shared/package"
/**
* Type for versioned settings where the version is the key.
* Each version key maps to a settings object that should be used
* when the current plugin version is >= that version.
*
* Example API response:
* ```
* {
* settings: {
* includedTools: ['search_replace'] // Plain value for old clients
* },
* versionedSettings: {
* '3.36.4': {
* includedTools: ['search_replace', 'apply_diff'], // Enhanced value for 3.36.4+
* excludedTools: ['write_to_file'],
* },
* '3.35.0': {
* includedTools: ['search_replace'], // Value for 3.35.0 - 3.36.3
* },
* }
* }
* ```
*
* The resolver will find the highest version key that is <= the current plugin version
* and use those settings. If no version matches, falls back to plain `settings`.
*/
export type VersionedSettings = Record<string, Record<string, unknown>>
/**
* Compares two semantic version strings using semver-compare.
*
* @param version1 First version string (e.g., "3.36.4")
* @param version2 Second version string (e.g., "3.36.0")
* @returns -1 if version1 < version2, 0 if equal, 1 if version1 > version2
*/
export function compareSemver(version1: string, version2: string): number {
// Handle pre-release versions by stripping the suffix
// semver-compare doesn't handle pre-release properly
const stripPrerelease = (v: string): string => v.split("-")[0]
return cmp(stripPrerelease(version1), stripPrerelease(version2))
}
/**
* Checks if the current plugin version meets or exceeds the required minimum version.
*
* @param minPluginVersion The minimum required version
* @param currentVersion The current plugin version (defaults to Package.version)
* @returns true if current version >= minPluginVersion
*/
export function meetsMinimumVersion(minPluginVersion: string, currentVersion: string = Package.version): boolean {
return compareSemver(currentVersion, minPluginVersion) >= 0
}
/**
* Finds the highest version from versionedSettings that is <= the current plugin version.
*
* @param versionedSettings The versioned settings object with version keys
* @param currentVersion The current plugin version (defaults to Package.version)
* @returns The highest matching version key, or undefined if none match
*/
export function findHighestMatchingVersion(
versionedSettings: VersionedSettings,
currentVersion: string = Package.version,
): string | undefined {
const versions = Object.keys(versionedSettings)
// Filter to versions that are <= currentVersion
const matchingVersions = versions.filter((version) => meetsMinimumVersion(version, currentVersion))
if (matchingVersions.length === 0) {
return undefined
}
// Sort in descending order and return the highest
matchingVersions.sort((a, b) => compareSemver(b, a))
return matchingVersions[0]
}
/**
* Resolves versioned settings by finding the highest version that is <= the current
* plugin version and returning those settings.
*
* The versionedSettings structure uses version numbers as keys:
* ```
* versionedSettings: {
* '3.36.4': { includedTools: ['search_replace'], excludedTools: ['apply_diff'] },
* '3.35.0': { includedTools: ['search_replace'] },
* }
* ```
*
* This function finds the highest version key that is <= currentVersion and returns
* the corresponding settings object. If no version matches, returns an empty object.
*
* @param versionedSettings The versioned settings object with version keys
* @param currentVersion The current plugin version (defaults to Package.version)
* @returns The settings object for the highest matching version, or empty object if none match
*/
export function resolveVersionedSettings<T extends Record<string, unknown>>(
versionedSettings: VersionedSettings,
currentVersion: string = Package.version,
): Partial<T> {
const matchingVersion = findHighestMatchingVersion(versionedSettings, currentVersion)
if (!matchingVersion) {
return {}
}
return versionedSettings[matchingVersion] as Partial<T>
}

View file

@ -488,6 +488,7 @@
"safe-stable-stringify": "^2.5.0",
"sanitize-filename": "^1.6.3",
"say": "^0.16.0",
"semver-compare": "^1.0.0",
"serialize-error": "^12.0.0",
"shell-quote": "^1.8.2",
"simple-git": "^3.27.0",
@ -524,6 +525,7 @@
"@types/node-ipc": "^9.2.3",
"@types/proper-lockfile": "^4.1.4",
"@types/ps-tree": "^1.1.6",
"@types/semver-compare": "^1.0.3",
"@types/shell-quote": "^1.7.5",
"@types/stream-json": "^1.7.8",
"@types/string-similarity": "^4.0.2",