From aebcaa263e5b7a9e4c8cde9261a0f932bd2b7407 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 7 Feb 2026 19:43:44 -0700 Subject: [PATCH] feat: show retired-provider message for removed provider profiles Preserve API profiles that reference removed providers instead of silently stripping their apiProvider. When a user selects a profile configured for a retired provider, the settings UI now shows an empathetic message explaining the removal instead of the provider configuration form. - Add retiredProviderNames array and isRetiredProvider() helper to packages/types/src/provider-settings.ts - Update ProviderSettingsManager sanitization to preserve retired providers (only strip truly unknown values) - Update ContextProxy sanitization to preserve retired providers - Render retired-provider message in ApiOptions.tsx when selected provider is in the retired list - Add tests for sanitization, ContextProxy, and UI behavior --- packages/types/src/provider-settings.ts | 31 +- src/api/index.ts | 8 +- src/core/config/ContextProxy.ts | 21 +- src/core/config/ProviderSettingsManager.ts | 32 +- .../config/__tests__/ContextProxy.spec.ts | 58 +- .../__tests__/ProviderSettingsManager.spec.ts | 124 +++- src/core/task/Task.ts | 25 +- src/core/webview/ClineProvider.ts | 12 +- webview-ui/src/components/chat/ChatView.tsx | 5 + .../chat/RetiredProviderWarning.tsx | 34 + .../src/components/settings/ApiOptions.tsx | 656 ++++++++++-------- .../src/components/settings/ModelPicker.tsx | 11 +- .../settings/__tests__/ApiOptions.spec.tsx | 27 + .../components/ui/hooks/useSelectedModel.ts | 34 +- webview-ui/src/index.css | 4 + webview-ui/src/utils/validate.ts | 4 +- 16 files changed, 731 insertions(+), 355 deletions(-) create mode 100644 webview-ui/src/components/chat/RetiredProviderWarning.tsx diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 31064be09b..fef422666d 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -129,6 +129,33 @@ export type ProviderName = z.infer export const isProviderName = (key: unknown): key is ProviderName => typeof key === "string" && providerNames.includes(key as ProviderName) +/** + * RetiredProviderName + */ + +export const retiredProviderNames = [ + "cerebras", + "chutes", + "deepinfra", + "doubao", + "featherless", + "groq", + "huggingface", + "io-intelligence", + "unbound", +] as const + +export const retiredProviderNamesSchema = z.enum(retiredProviderNames) + +export type RetiredProviderName = z.infer + +export const isRetiredProvider = (value: string): value is RetiredProviderName => + retiredProviderNames.includes(value as RetiredProviderName) + +export const providerNamesWithRetiredSchema = z.union([providerNamesSchema, retiredProviderNamesSchema]) + +export type ProviderNameWithRetired = z.infer + /** * ProviderSettingsEntry */ @@ -136,7 +163,7 @@ export const isProviderName = (key: unknown): key is ProviderName => export const providerSettingsEntrySchema = z.object({ id: z.string(), name: z.string(), - apiProvider: providerNamesSchema.optional(), + apiProvider: providerNamesWithRetiredSchema.optional(), modelId: z.string().optional(), }) @@ -386,7 +413,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv ]) export const providerSettingsSchema = z.object({ - apiProvider: providerNamesSchema.optional(), + apiProvider: providerNamesWithRetiredSchema.optional(), ...anthropicSchema.shape, ...openRouterSchema.shape, ...bedrockSchema.shape, diff --git a/src/api/index.ts b/src/api/index.ts index 78609d5173..0af7ab3a64 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" -import type { ProviderSettings, ModelInfo } from "@roo-code/types" +import { isRetiredProvider, type ProviderSettings, type ModelInfo } from "@roo-code/types" import { ApiStream } from "./transform/stream" @@ -119,6 +119,12 @@ export interface ApiHandler { export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration + if (apiProvider && isRetiredProvider(apiProvider)) { + throw new Error( + `Sorry, this provider is no longer supported. We saw very few Roo users actually using it and we need to reduce the surface area of our codebase so we can keep shipping fast and serving our community well in this space. It was a really hard decision but it lets us focus on what matters most to you. It sucks, we know.\n\nPlease select a different provider in your API profile settings.`, + ) + } + switch (apiProvider) { case "anthropic": return new AnthropicHandler(options) diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 87ce79a325..2825d1c945 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -16,6 +16,7 @@ import { globalSettingsSchema, isSecretStateKey, isProviderName, + isRetiredProvider, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -223,14 +224,16 @@ export class ContextProxy { } /** - * Migrates invalid/removed apiProvider values by clearing them from storage. - * This handles cases where a user had a provider selected that was later removed - * from the extension (e.g., "glama"). + * Migrates unknown apiProvider values by clearing them from storage. + * Retired providers are preserved so users can keep historical configuration. */ private async migrateInvalidApiProvider() { try { const apiProvider = this.stateCache.apiProvider - if (apiProvider !== undefined && !isProviderName(apiProvider)) { + const isKnownProvider = + typeof apiProvider === "string" && (isProviderName(apiProvider) || isRetiredProvider(apiProvider)) + + if (apiProvider !== undefined && !isKnownProvider) { logger.info(`[ContextProxy] Found invalid provider "${apiProvider}" in storage - clearing it`) // Clear the invalid provider from both cache and storage this.stateCache.apiProvider = undefined @@ -439,8 +442,8 @@ export class ContextProxy { } /** - * Sanitizes provider values by resetting invalid/removed apiProvider values. - * This prevents schema validation errors for removed providers. + * Sanitizes provider values by resetting unknown apiProvider values. + * Active and retired providers are preserved. */ private sanitizeProviderValues(values: RooCodeSettings): RooCodeSettings { // Remove legacy Claude Code CLI wrapper keys that may still exist in global state. @@ -456,7 +459,11 @@ export class ContextProxy { } } - if (values.apiProvider !== undefined && !isProviderName(values.apiProvider)) { + const isKnownProvider = + typeof values.apiProvider === "string" && + (isProviderName(values.apiProvider) || isRetiredProvider(values.apiProvider)) + + if (values.apiProvider !== undefined && !isKnownProvider) { logger.info(`[ContextProxy] Sanitizing invalid provider "${values.apiProvider}" - resetting to undefined`) // Return a new values object without the invalid apiProvider const { apiProvider, ...restValues } = sanitizedValues diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 3024540b67..4e7e1d1e20 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -12,6 +12,7 @@ import { getModelId, type ProviderName, isProviderName, + isRetiredProvider, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" @@ -359,8 +360,12 @@ export class ProviderSettingsManager { const existingId = providerProfiles.apiConfigs[name]?.id const id = config.id || existingId || this.generateId() - // Filter out settings from other providers. - const filteredConfig = discriminatedProviderSettingsWithIdSchema.parse(config) + // For active providers, filter out settings from other providers. + // For retired providers, preserve full profile fields to avoid data loss. + const filteredConfig = + typeof config.apiProvider === "string" && isRetiredProvider(config.apiProvider) + ? providerSettingsWithIdSchema.parse(config) + : discriminatedProviderSettingsWithIdSchema.parse(config) providerProfiles.apiConfigs[name] = { ...filteredConfig, id } await this.store(providerProfiles) return id @@ -507,7 +512,14 @@ export class ProviderSettingsManager { const profiles = providerProfilesSchema.parse(await this.load()) const configs = profiles.apiConfigs for (const name in configs) { - // Avoid leaking properties from other providers. + const apiProvider = configs[name].apiProvider + + if (typeof apiProvider === "string" && isRetiredProvider(apiProvider)) { + // Preserve retired-provider profiles as-is to prevent dropping legacy fields. + continue + } + + // Avoid leaking properties from other active providers. configs[name] = discriminatedProviderSettingsWithIdSchema.parse(configs[name]) // If it has no apiProvider, skip filtering @@ -607,7 +619,8 @@ export class ProviderSettingsManager { } /** - * Sanitizes a provider config by resetting invalid/removed apiProvider values. + * Sanitizes a provider config by resetting unknown apiProvider values. + * Retired providers are preserved. * This handles cases where a user had a provider selected that was later removed * from the extension (e.g., "glama"). */ @@ -618,10 +631,15 @@ export class ProviderSettingsManager { const config = apiConfig as Record - // Check if apiProvider is set and if it's still valid - if (config.apiProvider !== undefined && !isProviderName(config.apiProvider)) { + const apiProvider = config.apiProvider + + // Check if apiProvider is set and if it's still recognized (active or retired) + if ( + apiProvider !== undefined && + (typeof apiProvider !== "string" || (!isProviderName(apiProvider) && !isRetiredProvider(apiProvider))) + ) { console.log( - `[ProviderSettingsManager] Sanitizing invalid provider "${config.apiProvider}" - resetting to undefined`, + `[ProviderSettingsManager] Sanitizing unknown provider "${config.apiProvider}" - resetting to undefined`, ) // Return a new config object without the invalid apiProvider // This effectively resets the profile so the user can select a valid provider diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 2060260c6c..7c1d2a6e3c 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -424,7 +424,7 @@ describe("ContextProxy", () => { it("should reinitialize caches after reset", async () => { // Spy on initialization methods - const initializeSpy = vi.spyOn(proxy as any, "initialize") + const initializeSpy = vi.spyOn(proxy, "initialize") // Reset all state await proxy.resetAllState() @@ -452,6 +452,25 @@ describe("ContextProxy", () => { expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", undefined) }) + it("should not clear retired apiProvider from storage during initialization", async () => { + // Reset and create a new proxy with retired provider in state + vi.clearAllMocks() + mockGlobalState.get.mockImplementation((key: string) => { + if (key === "apiProvider") { + return "groq" // Retired provider + } + return undefined + }) + + const proxyWithRetiredProvider = new ContextProxy(mockContext) + await proxyWithRetiredProvider.initialize() + + // Should NOT have called update for apiProvider (retired should be preserved) + const updateCalls = mockGlobalState.update.mock.calls + const apiProviderUpdateCalls = updateCalls.filter((call: unknown[]) => call[0] === "apiProvider") + expect(apiProviderUpdateCalls).toHaveLength(0) + }) + it("should not modify valid apiProvider during initialization", async () => { // Reset and create a new proxy with valid provider in state vi.clearAllMocks() @@ -467,18 +486,29 @@ describe("ContextProxy", () => { // Should NOT have called update for apiProvider (it's valid) const updateCalls = mockGlobalState.update.mock.calls - const apiProviderUpdateCalls = updateCalls.filter((call: any[]) => call[0] === "apiProvider") + const apiProviderUpdateCalls = updateCalls.filter((call: unknown[]) => call[0] === "apiProvider") expect(apiProviderUpdateCalls.length).toBe(0) }) }) describe("getProviderSettings", () => { it("should sanitize invalid apiProvider before parsing", async () => { - // Set an invalid provider in state - await proxy.updateGlobalState("apiProvider", "invalid-removed-provider" as any) - await proxy.updateGlobalState("apiModelId", "some-model") + // Reset and create a new proxy with an unknown provider in state + vi.clearAllMocks() + mockGlobalState.get.mockImplementation((key: string) => { + if (key === "apiProvider") { + return "invalid-removed-provider" + } + if (key === "apiModelId") { + return "some-model" + } + return undefined + }) - const settings = proxy.getProviderSettings() + const proxyWithInvalidProvider = new ContextProxy(mockContext) + await proxyWithInvalidProvider.initialize() + + const settings = proxyWithInvalidProvider.getProviderSettings() // The invalid apiProvider should be sanitized (removed) expect(settings.apiProvider).toBeUndefined() @@ -486,6 +516,22 @@ describe("ContextProxy", () => { expect(settings.apiModelId).toBe("some-model") }) + it("should preserve retired apiProvider and provider fields", async () => { + await proxy.setValues({ + apiProvider: "groq", + apiModelId: "llama3-70b", + openAiBaseUrl: "https://api.retired-provider.example/v1", + apiKey: "retired-provider-key", + }) + + const settings = proxy.getProviderSettings() + + expect(settings.apiProvider).toBe("groq") + expect(settings.apiModelId).toBe("llama3-70b") + expect(settings.openAiBaseUrl).toBe("https://api.retired-provider.example/v1") + expect(settings.apiKey).toBe("retired-provider-key") + }) + it("should pass through valid apiProvider", async () => { // Set a valid provider in state await proxy.updateGlobalState("apiProvider", "anthropic") diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index e233fc913c..9a11ecb550 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -566,6 +566,42 @@ describe("ProviderSettingsManager", () => { "Failed to save config: Error: Failed to write provider profiles to secrets: Error: Storage failed", ) }) + + it("should preserve full fields when saving retired provider profiles", async () => { + mockSecrets.get.mockResolvedValue( + JSON.stringify({ + currentApiConfigName: "default", + apiConfigs: { + default: {}, + }, + modeApiConfigs: { + code: "default", + architect: "default", + ask: "default", + }, + }), + ) + + const retiredConfig: ProviderSettings = { + apiProvider: "groq", + apiKey: "legacy-key", + apiModelId: "legacy-model", + openAiBaseUrl: "https://legacy.example/v1", + openAiApiKey: "legacy-openai-key", + modelMaxTokens: 4096, + } + + await providerSettingsManager.saveConfig("retired", retiredConfig) + + const storedConfig = JSON.parse(mockSecrets.store.mock.calls[mockSecrets.store.mock.calls.length - 1][1]) + expect(storedConfig.apiConfigs.retired.apiProvider).toBe("groq") + expect(storedConfig.apiConfigs.retired.apiKey).toBe("legacy-key") + expect(storedConfig.apiConfigs.retired.apiModelId).toBe("legacy-model") + expect(storedConfig.apiConfigs.retired.openAiBaseUrl).toBe("https://legacy.example/v1") + expect(storedConfig.apiConfigs.retired.openAiApiKey).toBe("legacy-openai-key") + expect(storedConfig.apiConfigs.retired.modelMaxTokens).toBe(4096) + expect(storedConfig.apiConfigs.retired.id).toBeTruthy() + }) }) describe("DeleteConfig", () => { @@ -695,9 +731,9 @@ describe("ProviderSettingsManager", () => { ) }) - it("should sanitize invalid/removed providers by resetting apiProvider to undefined", async () => { + it("should sanitize unknown providers by resetting apiProvider to undefined", async () => { // This tests the fix for the infinite loop issue when a provider is removed - const configWithRemovedProvider = { + const configWithUnknownProvider = { currentApiConfigName: "valid", apiConfigs: { valid: { @@ -706,8 +742,8 @@ describe("ProviderSettingsManager", () => { apiModelId: "claude-3-opus-20240229", id: "valid-id", }, - removedProvider: { - // Provider that was removed from the extension (e.g., "invalid-removed-provider") + unknownProvider: { + // Provider value that is neither active nor retired. id: "removed-id", apiProvider: "invalid-removed-provider", apiKey: "some-key", @@ -722,7 +758,7 @@ describe("ProviderSettingsManager", () => { }, } - mockSecrets.get.mockResolvedValue(JSON.stringify(configWithRemovedProvider)) + mockSecrets.get.mockResolvedValue(JSON.stringify(configWithUnknownProvider)) await providerSettingsManager.initialize() @@ -735,11 +771,51 @@ describe("ProviderSettingsManager", () => { expect(storedConfig.apiConfigs.valid).toBeDefined() expect(storedConfig.apiConfigs.valid.apiProvider).toBe("anthropic") - // The config with the removed provider should have its apiProvider reset to undefined + // The config with the unknown provider should have its apiProvider reset to undefined // but still be present (not filtered out entirely) - expect(storedConfig.apiConfigs.removedProvider).toBeDefined() - expect(storedConfig.apiConfigs.removedProvider.apiProvider).toBeUndefined() - expect(storedConfig.apiConfigs.removedProvider.id).toBe("removed-id") + expect(storedConfig.apiConfigs.unknownProvider).toBeDefined() + expect(storedConfig.apiConfigs.unknownProvider.apiProvider).toBeUndefined() + expect(storedConfig.apiConfigs.unknownProvider.id).toBe("removed-id") + }) + + it("should preserve retired providers and their fields during initialize", async () => { + const configWithRetiredProvider = { + currentApiConfigName: "retiredProvider", + apiConfigs: { + retiredProvider: { + id: "retired-id", + apiProvider: "groq", + apiKey: "legacy-key", + apiModelId: "legacy-model", + openAiBaseUrl: "https://legacy.example/v1", + modelMaxTokens: 1024, + }, + }, + migrations: { + rateLimitSecondsMigrated: false, + openAiHeadersMigrated: true, + consecutiveMistakeLimitMigrated: true, + todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, + }, + } + + mockGlobalState.get.mockResolvedValue(0) + mockSecrets.get.mockResolvedValue(JSON.stringify(configWithRetiredProvider)) + + await providerSettingsManager.initialize() + + const storeCalls = mockSecrets.store.mock.calls + expect(storeCalls.length).toBeGreaterThan(0) + const finalStoredConfigJson = storeCalls[storeCalls.length - 1][1] + const storedConfig = JSON.parse(finalStoredConfigJson) + + expect(storedConfig.apiConfigs.retiredProvider).toBeDefined() + expect(storedConfig.apiConfigs.retiredProvider.apiProvider).toBe("groq") + expect(storedConfig.apiConfigs.retiredProvider.apiKey).toBe("legacy-key") + expect(storedConfig.apiConfigs.retiredProvider.apiModelId).toBe("legacy-model") + expect(storedConfig.apiConfigs.retiredProvider.openAiBaseUrl).toBe("https://legacy.example/v1") + expect(storedConfig.apiConfigs.retiredProvider.modelMaxTokens).toBe(1024) }) it("should sanitize invalid providers and remove non-object profiles during load", async () => { @@ -791,6 +867,36 @@ describe("ProviderSettingsManager", () => { }) }) + describe("Export", () => { + it("should preserve retired provider profiles with full fields", async () => { + const existingConfig: ProviderProfiles = { + currentApiConfigName: "retired", + apiConfigs: { + retired: { + id: "retired-id", + apiProvider: "groq", + apiKey: "legacy-key", + apiModelId: "legacy-model", + openAiBaseUrl: "https://legacy.example/v1", + modelMaxTokens: 4096, + modelMaxThinkingTokens: 2048, + }, + }, + } + + mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig)) + + const exported = await providerSettingsManager.export() + + expect(exported.apiConfigs.retired.apiProvider).toBe("groq") + expect(exported.apiConfigs.retired.apiKey).toBe("legacy-key") + expect(exported.apiConfigs.retired.apiModelId).toBe("legacy-model") + expect(exported.apiConfigs.retired.openAiBaseUrl).toBe("https://legacy.example/v1") + expect(exported.apiConfigs.retired.modelMaxTokens).toBe(4096) + expect(exported.apiConfigs.retired.modelMaxThinkingTokens).toBe(2048) + }) + }) + describe("ResetAllConfigs", () => { it("should delete all stored configs", async () => { // Setup initial config diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f4e41c1bfd..9a111cdcb0 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -41,6 +41,7 @@ import { TodoItem, getApiProtocol, getModelId, + isRetiredProvider, isIdleAsk, isInteractiveAsk, isResumableAsk, @@ -1035,7 +1036,11 @@ export class Task extends EventEmitter implements TaskLike { // Other providers (notably Gemini 3) use different signature semantics (e.g. `thoughtSignature`) // and require round-tripping the signature in their own format. const modelId = getModelId(this.apiConfiguration) - const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + const apiProvider = this.apiConfiguration.apiProvider + const apiProtocol = getApiProtocol( + apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, + modelId, + ) const isAnthropicProtocol = apiProtocol === "anthropic" // Start from the original assistant message @@ -2695,7 +2700,11 @@ export class Task extends EventEmitter implements TaskLike { // Determine API protocol based on provider and model const modelId = getModelId(this.apiConfiguration) - const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + const apiProvider = this.apiConfiguration.apiProvider + const apiProtocol = getApiProtocol( + apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, + modelId, + ) // Respect user-configured provider rate limiting BEFORE we emit api_req_started. // This prevents the UI from showing an "API Request..." spinner while we are @@ -2816,7 +2825,11 @@ export class Task extends EventEmitter implements TaskLike { // Calculate total tokens and cost using provider-aware function const modelId = getModelId(this.apiConfiguration) - const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + const apiProvider = this.apiConfiguration.apiProvider + const apiProtocol = getApiProtocol( + apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, + modelId, + ) const costResult = apiProtocol === "anthropic" @@ -3140,7 +3153,11 @@ export class Task extends EventEmitter implements TaskLike { // Capture telemetry with provider-aware cost calculation const modelId = getModelId(this.apiConfiguration) - const apiProtocol = getApiProtocol(this.apiConfiguration.apiProvider, modelId) + const apiProvider = this.apiConfiguration.apiProvider + const apiProtocol = getApiProtocol( + apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, + modelId, + ) // Use the appropriate cost function based on the API protocol const costResult = diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b598a27c27..fe11ae7dda 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -45,6 +45,7 @@ import { DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, + isRetiredProvider, } from "@roo-code/types" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" @@ -2266,8 +2267,11 @@ export class ClineProvider const stateValues = this.contextProxy.getValues() const customModes = await this.customModesManager.getCustomModes() - // Determine apiProvider with the same logic as before. - const apiProvider: ProviderName = stateValues.apiProvider ? stateValues.apiProvider : "anthropic" + // Determine apiProvider with the same logic as before, while filtering retired providers. + const apiProvider: ProviderName = + stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) + ? stateValues.apiProvider + : "anthropic" // Build the apiConfiguration object combining state values and secrets. const providerSettings = this.contextProxy.getProviderSettings() @@ -3105,12 +3109,14 @@ export class ClineProvider } } + const apiProvider = apiConfiguration?.apiProvider + return { language, mode, taskId: task?.taskId, parentTaskId: task?.parentTaskId, - apiProvider: apiConfiguration?.apiProvider, + apiProvider: apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, modelId: task?.api?.getModel().id, diffStrategy: task?.diffStrategy?.getName(), isSubtask: task ? !!task.parentTaskId : undefined, diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 5c377eb1f5..9a1d712be1 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -43,6 +43,7 @@ import { ChatTextArea } from "./ChatTextArea" import TaskHeader from "./TaskHeader" import SystemPromptWarning from "./SystemPromptWarning" import ProfileViolationWarning from "./ProfileViolationWarning" +import RetiredProviderWarning from "./RetiredProviderWarning" import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" import { WorktreeSelector } from "./WorktreeSelector" @@ -1779,6 +1780,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction +
+ +
+ {isProfileDisabled && (
diff --git a/webview-ui/src/components/chat/RetiredProviderWarning.tsx b/webview-ui/src/components/chat/RetiredProviderWarning.tsx new file mode 100644 index 0000000000..614230c779 --- /dev/null +++ b/webview-ui/src/components/chat/RetiredProviderWarning.tsx @@ -0,0 +1,34 @@ +import React from "react" + +import { isRetiredProvider } from "@roo-code/types" + +import { useExtensionState } from "@src/context/ExtensionStateContext" + +export const RetiredProviderWarning: React.FC = () => { + const { apiConfiguration } = useExtensionState() + + const provider = apiConfiguration?.apiProvider + if (!provider || !isRetiredProvider(provider)) { + return null + } + + return ( +
+
+ + Provider No Longer Supported +
+

+ Sorry, this provider is no longer supported. We saw very few Roo users actually using it and we need to + reduce the surface area of our codebase so we can keep shipping fast and serving our community well in + this space. It was a really hard decision but it lets us focus on what matters most to you. It sucks, we + know. +

+

+ Please select a different provider in your API profile settings. +

+
+ ) +} + +export default RetiredProviderWarning diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 87544529a0..3e08663607 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -7,6 +7,7 @@ import { ExternalLinkIcon } from "@radix-ui/react-icons" import { type ProviderName, type ProviderSettings, + isRetiredProvider, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, openRouterDefaultModelId, requestyDefaultModelId, @@ -179,6 +180,11 @@ const ApiOptions = ({ id: selectedModelId, info: selectedModelInfo, } = useSelectedModel(apiConfiguration) + const activeSelectedProvider: ProviderName | undefined = isRetiredProvider(selectedProvider) + ? undefined + : selectedProvider + const isRetiredSelectedProvider = + typeof apiConfiguration.apiProvider === "string" && isRetiredProvider(apiConfiguration.apiProvider) const { data: routerModels, refetch: refetchRouterModels } = useRouterModels() @@ -196,12 +202,16 @@ const ApiOptions = ({ // Update `apiModelId` whenever `selectedModelId` changes. useEffect(() => { + if (isRetiredSelectedProvider) { + return + } + if (selectedModelId && apiConfiguration.apiModelId !== selectedModelId) { // Pass false as third parameter to indicate this is not a user action // This is an internal sync, not a user-initiated change setApiConfigurationField("apiModelId", selectedModelId, false) } - }, [selectedModelId, setApiConfigurationField, apiConfiguration.apiModelId]) + }, [selectedModelId, setApiConfigurationField, apiConfiguration.apiModelId, isRetiredSelectedProvider]) // Debounced refresh model updates, only executed 250ms after the user // stops typing. @@ -245,13 +255,18 @@ const ApiOptions = ({ ) useEffect(() => { + if (isRetiredSelectedProvider) { + setErrorMessage(undefined) + return + } + const apiValidationResult = validateApiConfigurationExcludingModelErrors( apiConfiguration, routerModels, organizationAllowList, ) setErrorMessage(apiValidationResult) - }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage]) + }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage, isRetiredSelectedProvider]) const onProviderChange = useCallback( (value: ProviderName) => { @@ -469,311 +484,358 @@ const ApiOptions = ({ {errorMessage && } - {selectedProvider === "openrouter" && ( - - )} - - {selectedProvider === "requesty" && ( - - )} - - {selectedProvider === "anthropic" && ( - - )} - - {selectedProvider === "openai-codex" && ( - - )} - - {selectedProvider === "openai-native" && ( - - )} - - {selectedProvider === "mistral" && ( - - )} - - {selectedProvider === "baseten" && ( - - )} - - {selectedProvider === "bedrock" && ( - - )} - - {selectedProvider === "vertex" && ( - - )} - - {selectedProvider === "gemini" && ( - - )} - - {selectedProvider === "openai" && ( - - )} - - {selectedProvider === "lmstudio" && ( - - )} - - {selectedProvider === "deepseek" && ( - - )} - - {selectedProvider === "qwen-code" && ( - - )} - - {selectedProvider === "moonshot" && ( - - )} - - {selectedProvider === "minimax" && ( - - )} - - {selectedProvider === "vscode-lm" && ( - - )} - - {selectedProvider === "ollama" && ( - - )} - - {selectedProvider === "xai" && ( - - )} - - {selectedProvider === "litellm" && ( - - )} - - {selectedProvider === "sambanova" && ( - - )} - - {selectedProvider === "zai" && ( - - )} - - {selectedProvider === "vercel-ai-gateway" && ( - - )} - - {selectedProvider === "fireworks" && ( - - )} - - {selectedProvider === "roo" && ( - - )} - - {/* Generic model picker for providers with static models */} - {shouldUseGenericModelPicker(selectedProvider) && ( + {isRetiredSelectedProvider ? ( +
+ Sorry, this provider is no longer supported. We saw very few Roo users actually using it and we need + to reduce the surface area of our codebase so we can keep shipping fast and serving our community + well in this space. It was a really hard decision but it lets us focus on what matters most to you. + It sucks, we know. +
+ ) : ( <> - - handleModelChangeSideEffects(selectedProvider, modelId, setApiConfigurationField) - } - /> + {selectedProvider === "openrouter" && ( + + )} - {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( - + )} + + {selectedProvider === "anthropic" && ( + + )} + + {selectedProvider === "openai-codex" && ( + + )} + + {selectedProvider === "openai-native" && ( + + )} + + {selectedProvider === "mistral" && ( + + )} + + {selectedProvider === "baseten" && ( + + )} + + {selectedProvider === "bedrock" && ( + + )} + + {selectedProvider === "vertex" && ( + )} - - )} - {!fromWelcomeView && ( - - )} - - {/* Gate Verbosity UI by capability flag */} - {!fromWelcomeView && selectedModelInfo?.supportsVerbosity && ( - - )} - - {!fromWelcomeView && ( - - - - {t("settings:advancedSettings.title")} - - - setApiConfigurationField(field, value)} + {selectedProvider === "gemini" && ( + - {selectedModelInfo?.supportsTemperature !== false && ( - + )} + + {selectedProvider === "lmstudio" && ( + + )} + + {selectedProvider === "deepseek" && ( + + )} + + {selectedProvider === "qwen-code" && ( + + )} + + {selectedProvider === "moonshot" && ( + + )} + + {selectedProvider === "minimax" && ( + + )} + + {selectedProvider === "vscode-lm" && ( + + )} + + {selectedProvider === "ollama" && ( + + )} + + {selectedProvider === "xai" && ( + + )} + + {selectedProvider === "litellm" && ( + + )} + + {selectedProvider === "sambanova" && ( + + )} + + {selectedProvider === "zai" && ( + + )} + + {selectedProvider === "vercel-ai-gateway" && ( + + )} + + {selectedProvider === "fireworks" && ( + + )} + + {selectedProvider === "roo" && ( + + )} + + {/* Generic model picker for providers with static models */} + {activeSelectedProvider && shouldUseGenericModelPicker(activeSelectedProvider) && ( + <> + + handleModelChangeSideEffects( + activeSelectedProvider, + modelId, + setApiConfigurationField, + ) + } /> - )} - setApiConfigurationField("rateLimitSeconds", value)} - /> - setApiConfigurationField("consecutiveMistakeLimit", value)} - /> - {selectedProvider === "openrouter" && - openRouterModelProviders && - Object.keys(openRouterModelProviders).length > 0 && ( -
-
- - - - -
- -
- {t("settings:providers.openRouter.providerRouting.description")}{" "} - - {t("settings:providers.openRouter.providerRouting.learnMore")}. - -
-
+ + {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( + )} -
-
+ + )} + + {!fromWelcomeView && ( + + )} + + {/* Gate Verbosity UI by capability flag */} + {!fromWelcomeView && selectedModelInfo?.supportsVerbosity && ( + + )} + + {!fromWelcomeView && ( + + + + {t("settings:advancedSettings.title")} + + + setApiConfigurationField(field, value)} + /> + {selectedModelInfo?.supportsTemperature !== false && ( + + )} + setApiConfigurationField("rateLimitSeconds", value)} + /> + setApiConfigurationField("consecutiveMistakeLimit", value)} + /> + {selectedProvider === "openrouter" && + openRouterModelProviders && + Object.keys(openRouterModelProviders).length > 0 && ( +
+
+ + + + +
+ +
+ {t("settings:providers.openRouter.providerRouting.description")}{" "} + + {t("settings:providers.openRouter.providerRouting.learnMore")}. + +
+
+ )} +
+
+ )} + )}
) diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index 82b3c41f67..7db427e3ee 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -3,7 +3,7 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { Trans } from "react-i18next" import { ChevronsUpDown, Check, X, Info } from "lucide-react" -import type { ProviderSettings, ModelInfo, OrganizationAllowList } from "@roo-code/types" +import { type ProviderSettings, type ModelInfo, type OrganizationAllowList, isRetiredProvider } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" @@ -104,8 +104,13 @@ export const ModelPicker = ({ return selectedModelId }, [displayTransform, apiConfiguration, modelIdKey, selectedModelId]) + const activeProvider = + apiConfiguration.apiProvider && isRetiredProvider(apiConfiguration.apiProvider) + ? undefined + : apiConfiguration.apiProvider + const modelIds = useMemo(() => { - const filteredModels = filterModels(models, apiConfiguration.apiProvider, organizationAllowList) + const filteredModels = filterModels(models, activeProvider, organizationAllowList) // Include the currently selected model even if deprecated (so users can see what they have selected) // But filter out other deprecated models from being newly selectable @@ -125,7 +130,7 @@ export const ModelPicker = ({ ) return Object.keys(availableModels).sort((a, b) => a.localeCompare(b)) - }, [models, apiConfiguration.apiProvider, organizationAllowList, selectedModelId]) + }, [models, activeProvider, organizationAllowList, selectedModelId]) const [searchValue, setSearchValue] = useState("") diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx index 95f9207c62..f862087dd0 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -662,4 +662,31 @@ describe("ApiOptions", () => { useExtensionStateMock.mockRestore() }) }) + + it("renders retired provider message and hides provider-specific forms", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "groq", + }, + }) + + expect(screen.getByTestId("retired-provider-message")).toHaveTextContent( + "Sorry, this provider is no longer supported. We saw very few Roo users actually using it and we need to reduce the surface area of our codebase so we can keep shipping fast and serving our community well in this space. It was a really hard decision but it lets us focus on what matters most to you. It sucks, we know.", + ) + expect(screen.queryByTestId("litellm-provider")).not.toBeInTheDocument() + }) + + it("does not reintroduce retired providers into active provider options", () => { + renderApiOptions({ + apiConfiguration: { + apiProvider: "groq", + }, + }) + + const providerSelectContainer = screen.getByTestId("provider-select") + const providerSelect = providerSelectContainer.querySelector("select") as HTMLSelectElement + const providerOptions = Array.from(providerSelect.querySelectorAll("option")).map((option) => option.value) + + expect(providerOptions).not.toContain("groq") + }) }) diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 7be7c353ba..0ac82b5062 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -29,6 +29,7 @@ import { BEDROCK_1M_CONTEXT_MODEL_IDS, VERTEX_1M_CONTEXT_MODEL_IDS, isDynamicProvider, + isRetiredProvider, getProviderDefaultModelId, } from "@roo-code/types" @@ -51,14 +52,16 @@ function getValidatedModelId( export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { const provider = apiConfiguration?.apiProvider || "anthropic" - const openRouterModelId = provider === "openrouter" ? apiConfiguration?.openRouterModelId : undefined - const lmStudioModelId = provider === "lmstudio" ? apiConfiguration?.lmStudioModelId : undefined - const ollamaModelId = provider === "ollama" ? apiConfiguration?.ollamaModelId : undefined + const activeProvider: ProviderName | undefined = isRetiredProvider(provider) ? undefined : provider + const dynamicProvider = activeProvider && isDynamicProvider(activeProvider) ? activeProvider : undefined + const openRouterModelId = activeProvider === "openrouter" ? apiConfiguration?.openRouterModelId : undefined + const lmStudioModelId = activeProvider === "lmstudio" ? apiConfiguration?.lmStudioModelId : undefined + const ollamaModelId = activeProvider === "ollama" ? apiConfiguration?.ollamaModelId : undefined // Only fetch router models for dynamic providers - const shouldFetchRouterModels = isDynamicProvider(provider) + const shouldFetchRouterModels = !!dynamicProvider const routerModels = useRouterModels({ - provider: shouldFetchRouterModels ? provider : undefined, + provider: dynamicProvider, enabled: shouldFetchRouterModels, }) @@ -68,16 +71,17 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { // Compute readiness only for the data actually needed for the selected provider const needRouterModels = shouldFetchRouterModels - const needOpenRouterProviders = provider === "openrouter" + const needOpenRouterProviders = activeProvider === "openrouter" const needLmStudio = typeof lmStudioModelId !== "undefined" const needOllama = typeof ollamaModelId !== "undefined" - const hasValidRouterData = needRouterModels - ? routerModels.data && - routerModels.data[provider] !== undefined && - typeof routerModels.data[provider] === "object" && - !routerModels.isLoading - : true + const hasValidRouterData = + needRouterModels && dynamicProvider + ? routerModels.data && + routerModels.data[dynamicProvider] !== undefined && + typeof routerModels.data[dynamicProvider] === "object" && + !routerModels.isLoading + : true const isReady = (!needLmStudio || typeof lmStudioModels.data !== "undefined") && @@ -86,16 +90,16 @@ export const useSelectedModel = (apiConfiguration?: ProviderSettings) => { (!needOpenRouterProviders || typeof openRouterModelProviders.data !== "undefined") const { id, info } = - apiConfiguration && isReady + apiConfiguration && isReady && activeProvider ? getSelectedModel({ - provider, + provider: activeProvider, apiConfiguration, routerModels: (routerModels.data || {}) as RouterModels, openRouterModelProviders: (openRouterModelProviders.data || {}) as Record, lmStudioModels: (lmStudioModels.data || undefined) as ModelRecord | undefined, ollamaModels: (ollamaModels.data || undefined) as ModelRecord | undefined, }) - : { id: getProviderDefaultModelId(provider), info: undefined } + : { id: getProviderDefaultModelId(activeProvider ?? "anthropic"), info: undefined } return { provider, diff --git a/webview-ui/src/index.css b/webview-ui/src/index.css index 2395b69f57..ebb9a2fb90 100644 --- a/webview-ui/src/index.css +++ b/webview-ui/src/index.css @@ -131,6 +131,10 @@ --color-vscode-inputValidation-infoBackground: var(--vscode-inputValidation-infoBackground); --color-vscode-inputValidation-infoBorder: var(--vscode-inputValidation-infoBorder); + --color-vscode-inputValidation-warningForeground: var(--vscode-inputValidation-warningForeground); + --color-vscode-inputValidation-warningBackground: var(--vscode-inputValidation-warningBackground); + --color-vscode-inputValidation-warningBorder: var(--vscode-inputValidation-warningBorder); + --color-vscode-widget-border: var(--vscode-widget-border); --color-vscode-widget-shadow: var(--vscode-widget-shadow); diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index bb57da3266..116013d03f 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -7,6 +7,7 @@ import { type RouterModels, modelIdKeysByProvider, isProviderName, + isRetiredProvider, isDynamicProvider, isFauxProvider, isCustomProvider, @@ -153,7 +154,8 @@ function validateProviderAgainstOrganizationSettings( } if (!providerConfig.allowAll) { - const modelId = getModelIdForProvider(apiConfiguration, provider) + const activeProvider = isRetiredProvider(provider) ? undefined : provider + const modelId = activeProvider ? getModelIdForProvider(apiConfiguration, activeProvider) : undefined const allowedModels = providerConfig.models || [] if (modelId && !allowedModels.includes(modelId)) {