Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/core/config/ProviderSettingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { TelemetryService } from "@roo-code/telemetry"

import { Mode, modes } from "../../shared/modes"
import { buildApiHandler } from "../../api"

// Type-safe model migrations mapping
type ModelMigrations = {
Expand Down Expand Up @@ -528,6 +529,25 @@ export class ProviderSettingsManager {
for (const name in configs) {
// Avoid leaking properties from other providers.
configs[name] = discriminatedProviderSettingsWithIdSchema.parse(configs[name])

// If it has no apiProvider, skip filtering
if (!configs[name].apiProvider) {
continue
}

// Try to build an API handler to get model information
const apiHandler = buildApiHandler(configs[name])
const modelInfo = apiHandler.getModel().info

// Check if the model supports reasoning budgets
const supportsThinkingBudget =
modelInfo.supportsReasoningBudget || modelInfo.requiredReasoningBudget

// If the model doesn't support reasoning budgets, remove the token fields
if (!supportsThinkingBudget) {
delete configs[name].modelMaxTokens
delete configs[name].modelMaxThinkingTokens
}
}
return profiles
})
Expand Down
261 changes: 261 additions & 0 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import { safeWriteJson } from "../../../utils/safeWriteJson"
import type { Mock } from "vitest"

vi.mock("vscode", () => ({
workspace: {
getConfiguration: vi.fn().mockReturnValue({
get: vi.fn(),
}),
},
window: {
showOpenDialog: vi.fn(),
showSaveDialog: vi.fn(),
Expand Down Expand Up @@ -58,6 +63,45 @@ vi.mock("os", () => ({

vi.mock("../../../utils/safeWriteJson")

// Mock buildApiHandler to avoid issues with provider instantiation in tests
vi.mock("../../../api", () => ({
buildApiHandler: vi.fn().mockImplementation((config) => {
// Return different model info based on the provider and model
const getModelInfo = () => {
if (config.apiProvider === "claude-code") {
return {
id: config.apiModelId || "claude-sonnet-4-5",
info: {
supportsReasoningBudget: false,
requiredReasoningBudget: false,
},
}
}
if (config.apiProvider === "anthropic" && config.apiModelId === "claude-3-5-sonnet-20241022") {
return {
id: "claude-3-5-sonnet-20241022",
info: {
supportsReasoningBudget: true,
requiredReasoningBudget: true,
},
}
}
// Default fallback
return {
id: config.apiModelId || "claude-sonnet-4-5",
info: {
supportsReasoningBudget: false,
requiredReasoningBudget: false,
},
}
}

return {
getModel: vi.fn().mockReturnValue(getModelInfo()),
}
}),
}))

describe("importExport", () => {
let mockProviderSettingsManager: ReturnType<typeof vi.mocked<ProviderSettingsManager>>
let mockContextProxy: ReturnType<typeof vi.mocked<ContextProxy>>
Expand Down Expand Up @@ -436,6 +480,71 @@ describe("importExport", () => {

showErrorMessageSpy.mockRestore()
})

it("should handle import when reasoning budget fields are missing from config", async () => {
// This test verifies that import works correctly when reasoning budget fields are not present
// Using claude-code provider which doesn't support reasoning budgets

;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])

const mockFileContent = JSON.stringify({
providerProfiles: {
currentApiConfigName: "claude-code-provider",
apiConfigs: {
"claude-code-provider": {
apiProvider: "claude-code" as ProviderName,
apiModelId: "claude-3-5-sonnet-20241022",
id: "claude-code-id",
apiKey: "test-key",
// No modelMaxTokens or modelMaxThinkingTokens fields
},
},
},
globalSettings: { mode: "code", autoApprovalEnabled: true },
})

;(fs.readFile as Mock).mockResolvedValue(mockFileContent)

const previousProviderProfiles = {
currentApiConfigName: "default",
apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } },
}

mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles)
mockProviderSettingsManager.listConfig.mockResolvedValue([
{ name: "claude-code-provider", id: "claude-code-id", apiProvider: "claude-code" as ProviderName },
{ name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName },
])

mockContextProxy.export.mockResolvedValue({ mode: "code" })

const result = await importSettings({
providerSettingsManager: mockProviderSettingsManager,
contextProxy: mockContextProxy,
customModesManager: mockCustomModesManager,
})

expect(result.success).toBe(true)
expect(fs.readFile).toHaveBeenCalledWith("/mock/path/settings.json", "utf-8")
expect(mockProviderSettingsManager.export).toHaveBeenCalled()

expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({
currentApiConfigName: "claude-code-provider",
apiConfigs: {
default: { apiProvider: "anthropic" as ProviderName, id: "default-id" },
"claude-code-provider": {
apiProvider: "claude-code" as ProviderName,
apiModelId: "claude-3-5-sonnet-20241022",
apiKey: "test-key",
id: "claude-code-id",
},
},
modeApiConfigs: {},
})

expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true })
expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "claude-code-provider")
})
})

describe("exportSettings", () => {
Expand Down Expand Up @@ -1608,5 +1717,157 @@ describe("importExport", () => {
"https://custom-api.example.com/v1",
)
})

it("should exclude modelMaxTokens and modelMaxThinkingTokens when supportsReasoningBudget is false", async () => {
// This test verifies that token fields are excluded when model doesn't support reasoning budget
// Using claude-code provider which has supportsReasoningBudget: false

;(vscode.window.showSaveDialog as Mock).mockResolvedValue({
fsPath: "/mock/path/roo-code-settings.json",
})

// Use a real ProviderSettingsManager instance to test the actual filtering logic
const realProviderSettingsManager = new ProviderSettingsManager(mockExtensionContext)

// Wait for initialization to complete
await realProviderSettingsManager.initialize()

// Save a claude-code provider config with token fields
await realProviderSettingsManager.saveConfig("claude-code-provider", {
apiProvider: "claude-code" as ProviderName,
apiModelId: "claude-sonnet-4-5",
id: "claude-code-id",
modelMaxTokens: 4096, // This should be removed during export
modelMaxThinkingTokens: 2048, // This should be removed during export
})

// Set this as the current provider
await realProviderSettingsManager.activateProfile({ name: "claude-code-provider" })

const mockGlobalSettings = {
mode: "code",
autoApprovalEnabled: true,
}

mockContextProxy.export.mockResolvedValue(mockGlobalSettings)
;(fs.mkdir as Mock).mockResolvedValue(undefined)

await exportSettings({
providerSettingsManager: realProviderSettingsManager,
contextProxy: mockContextProxy,
})

// Get the exported data
const exportedData = (safeWriteJson as Mock).mock.calls[0][1]

// Verify that token fields were excluded because supportsReasoningBudget is false
const provider = exportedData.providerProfiles.apiConfigs["claude-code-provider"]
expect(provider).toBeDefined()
expect(provider.apiModelId).toBe("claude-sonnet-4-5")
expect("modelMaxTokens" in provider).toBe(false) // Should be excluded
expect("modelMaxThinkingTokens" in provider).toBe(false) // Should be excluded
})

it("should exclude modelMaxTokens and modelMaxThinkingTokens when requiredReasoningBudget is false", async () => {
// This test verifies that token fields are excluded when model doesn't require reasoning budget
// Using claude-code provider which has requiredReasoningBudget: false

;(vscode.window.showSaveDialog as Mock).mockResolvedValue({
fsPath: "/mock/path/roo-code-settings.json",
})

// Use a real ProviderSettingsManager instance to test the actual filtering logic
const realProviderSettingsManager = new ProviderSettingsManager(mockExtensionContext)

// Wait for initialization to complete
await realProviderSettingsManager.initialize()

// Save a claude-code provider config with token fields
await realProviderSettingsManager.saveConfig("claude-code-provider-2", {
apiProvider: "claude-code" as ProviderName,
apiModelId: "claude-sonnet-4-5",
id: "claude-code-id-2",
apiKey: "test-key",
modelMaxTokens: 4096, // This should be removed during export
modelMaxThinkingTokens: 2048, // This should be removed during export
})

// Set this as the current provider
await realProviderSettingsManager.activateProfile({ name: "claude-code-provider-2" })

const mockGlobalSettings = {
mode: "code",
autoApprovalEnabled: true,
}

mockContextProxy.export.mockResolvedValue(mockGlobalSettings)
;(fs.mkdir as Mock).mockResolvedValue(undefined)

await exportSettings({
providerSettingsManager: realProviderSettingsManager,
contextProxy: mockContextProxy,
})

// Get the exported data
const exportedData = (safeWriteJson as Mock).mock.calls[0][1]

// Verify that token fields were excluded because requiredReasoningBudget is false
const provider = exportedData.providerProfiles.apiConfigs["claude-code-provider-2"]
expect(provider).toBeDefined()
expect(provider.apiModelId).toBe("claude-sonnet-4-5")
expect("modelMaxTokens" in provider).toBe(false) // Should be excluded
expect("modelMaxThinkingTokens" in provider).toBe(false) // Should be excluded
})

it("should exclude modelMaxTokens and modelMaxThinkingTokens when both supportsReasoningBudget and requiredReasoningBudget are false", async () => {
// This test verifies that token fields are excluded when model has both reasoning budget flags set to false
// Using claude-code provider which has both flags set to false

;(vscode.window.showSaveDialog as Mock).mockResolvedValue({
fsPath: "/mock/path/roo-code-settings.json",
})

// Use a real ProviderSettingsManager instance to test the actual filtering logic
const realProviderSettingsManager = new ProviderSettingsManager(mockExtensionContext)

// Wait for initialization to complete
await realProviderSettingsManager.initialize()

// Save a claude-code provider config with token fields
await realProviderSettingsManager.saveConfig("claude-code-provider-3", {
apiProvider: "claude-code" as ProviderName,
apiModelId: "claude-3-5-haiku-20241022", // Use a different model ID
id: "claude-code-id-3",
apiKey: "test-key",
modelMaxTokens: 4096, // This should be removed during export
modelMaxThinkingTokens: 2048, // This should be removed during export
})

// Set this as the current provider
await realProviderSettingsManager.activateProfile({ name: "claude-code-provider-3" })

const mockGlobalSettings = {
mode: "code",
autoApprovalEnabled: true,
}

mockContextProxy.export.mockResolvedValue(mockGlobalSettings)
;(fs.mkdir as Mock).mockResolvedValue(undefined)

await exportSettings({
providerSettingsManager: realProviderSettingsManager,
contextProxy: mockContextProxy,
})

// Get the exported data
const exportedData = (safeWriteJson as Mock).mock.calls[0][1]

// Verify that token fields were excluded because both reasoning budget flags are false
const provider = exportedData.providerProfiles.apiConfigs["claude-code-provider-3"]
expect(provider).toBeDefined()
expect(provider.apiModelId).toBe("claude-3-5-haiku-20241022")
expect("modelMaxTokens" in provider).toBe(false) // Should be excluded
expect("modelMaxThinkingTokens" in provider).toBe(false) // Should be excluded
})
})
})