diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 357a04b33a..81a5521b62 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -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 = { @@ -528,6 +529,31 @@ 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 + try { + const apiHandler = buildApiHandler(configs[name]) + const modelInfo = apiHandler.getModel().info + + // Check if the model supports reasoning budgets + const supportsReasoningBudget = + modelInfo.supportsReasoningBudget || modelInfo.requiredReasoningBudget + + // If the model doesn't support reasoning budgets, remove the token fields + if (!supportsReasoningBudget) { + delete configs[name].modelMaxTokens + delete configs[name].modelMaxThinkingTokens + } + } catch (error) { + // If we can't build the API handler or get model info, skip filtering + // to avoid accidental data loss from incomplete configurations + console.warn(`Skipping token field filtering for config '${name}': ${error}`) + } } return profiles }) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 361d6b23b0..3d5329f377 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -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(), @@ -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> let mockContextProxy: ReturnType> @@ -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", () => { @@ -1608,5 +1717,78 @@ describe("importExport", () => { "https://custom-api.example.com/v1", ) }) + + it.each([ + { + testCase: "supportsReasoningBudget is false", + providerName: "claude-code-provider", + modelId: "claude-sonnet-4-5", + providerId: "claude-code-id", + }, + { + testCase: "requiredReasoningBudget is false", + providerName: "claude-code-provider-2", + modelId: "claude-sonnet-4-5", + providerId: "claude-code-id-2", + }, + { + testCase: "both supportsReasoningBudget and requiredReasoningBudget are false", + providerName: "claude-code-provider-3", + modelId: "claude-3-5-haiku-20241022", + providerId: "claude-code-id-3", + }, + ])( + "should exclude modelMaxTokens and modelMaxThinkingTokens when $testCase", + async ({ providerName, modelId, providerId }) => { + // This test verifies that token fields are excluded when model doesn't support reasoning budget + // Using claude-code provider which has supportsReasoningBudget: false and 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(providerName, { + apiProvider: "claude-code" as ProviderName, + apiModelId: modelId, + id: providerId, + 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: providerName }) + + 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 reasoning budget is not supported/required + const provider = exportedData.providerProfiles.apiConfigs[providerName] + expect(provider).toBeDefined() + expect(provider.apiModelId).toBe(modelId) + expect("modelMaxTokens" in provider).toBe(false) // Should be excluded + expect("modelMaxThinkingTokens" in provider).toBe(false) // Should be excluded + }, + ) }) })