Skip to content

Commit 77cd108

Browse files
committed
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
1 parent e8158b4 commit 77cd108

12 files changed

Lines changed: 658 additions & 331 deletions

File tree

packages/types/src/provider-settings.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,41 @@ export type ProviderName = z.infer<typeof providerNamesSchema>
129129
export const isProviderName = (key: unknown): key is ProviderName =>
130130
typeof key === "string" && providerNames.includes(key as ProviderName)
131131

132+
/**
133+
* RetiredProviderName
134+
*/
135+
136+
export const retiredProviderNames = [
137+
"cerebras",
138+
"chutes",
139+
"deepinfra",
140+
"doubao",
141+
"featherless",
142+
"groq",
143+
"huggingface",
144+
"io-intelligence",
145+
"unbound",
146+
] as const
147+
148+
export const retiredProviderNamesSchema = z.enum(retiredProviderNames)
149+
150+
export type RetiredProviderName = z.infer<typeof retiredProviderNamesSchema>
151+
152+
export const isRetiredProvider = (value: string): value is RetiredProviderName =>
153+
retiredProviderNames.includes(value as RetiredProviderName)
154+
155+
export const providerNamesWithRetiredSchema = z.union([providerNamesSchema, retiredProviderNamesSchema])
156+
157+
export type ProviderNameWithRetired = z.infer<typeof providerNamesWithRetiredSchema>
158+
132159
/**
133160
* ProviderSettingsEntry
134161
*/
135162

136163
export const providerSettingsEntrySchema = z.object({
137164
id: z.string(),
138165
name: z.string(),
139-
apiProvider: providerNamesSchema.optional(),
166+
apiProvider: providerNamesWithRetiredSchema.optional(),
140167
modelId: z.string().optional(),
141168
})
142169

@@ -386,7 +413,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
386413
])
387414

388415
export const providerSettingsSchema = z.object({
389-
apiProvider: providerNamesSchema.optional(),
416+
apiProvider: providerNamesWithRetiredSchema.optional(),
390417
...anthropicSchema.shape,
391418
...openRouterSchema.shape,
392419
...bedrockSchema.shape,

src/core/config/ContextProxy.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
globalSettingsSchema,
1717
isSecretStateKey,
1818
isProviderName,
19+
isRetiredProvider,
1920
} from "@roo-code/types"
2021
import { TelemetryService } from "@roo-code/telemetry"
2122

@@ -223,14 +224,16 @@ export class ContextProxy {
223224
}
224225

225226
/**
226-
* Migrates invalid/removed apiProvider values by clearing them from storage.
227-
* This handles cases where a user had a provider selected that was later removed
228-
* from the extension (e.g., "glama").
227+
* Migrates unknown apiProvider values by clearing them from storage.
228+
* Retired providers are preserved so users can keep historical configuration.
229229
*/
230230
private async migrateInvalidApiProvider() {
231231
try {
232232
const apiProvider = this.stateCache.apiProvider
233-
if (apiProvider !== undefined && !isProviderName(apiProvider)) {
233+
const isKnownProvider =
234+
typeof apiProvider === "string" && (isProviderName(apiProvider) || isRetiredProvider(apiProvider))
235+
236+
if (apiProvider !== undefined && !isKnownProvider) {
234237
logger.info(`[ContextProxy] Found invalid provider "${apiProvider}" in storage - clearing it`)
235238
// Clear the invalid provider from both cache and storage
236239
this.stateCache.apiProvider = undefined
@@ -439,8 +442,8 @@ export class ContextProxy {
439442
}
440443

441444
/**
442-
* Sanitizes provider values by resetting invalid/removed apiProvider values.
443-
* This prevents schema validation errors for removed providers.
445+
* Sanitizes provider values by resetting unknown apiProvider values.
446+
* Active and retired providers are preserved.
444447
*/
445448
private sanitizeProviderValues(values: RooCodeSettings): RooCodeSettings {
446449
// Remove legacy Claude Code CLI wrapper keys that may still exist in global state.
@@ -456,7 +459,11 @@ export class ContextProxy {
456459
}
457460
}
458461

459-
if (values.apiProvider !== undefined && !isProviderName(values.apiProvider)) {
462+
const isKnownProvider =
463+
typeof values.apiProvider === "string" &&
464+
(isProviderName(values.apiProvider) || isRetiredProvider(values.apiProvider))
465+
466+
if (values.apiProvider !== undefined && !isKnownProvider) {
460467
logger.info(`[ContextProxy] Sanitizing invalid provider "${values.apiProvider}" - resetting to undefined`)
461468
// Return a new values object without the invalid apiProvider
462469
const { apiProvider, ...restValues } = sanitizedValues

src/core/config/ProviderSettingsManager.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getModelId,
1313
type ProviderName,
1414
isProviderName,
15+
isRetiredProvider,
1516
} from "@roo-code/types"
1617
import { TelemetryService } from "@roo-code/telemetry"
1718

@@ -359,8 +360,12 @@ export class ProviderSettingsManager {
359360
const existingId = providerProfiles.apiConfigs[name]?.id
360361
const id = config.id || existingId || this.generateId()
361362

362-
// Filter out settings from other providers.
363-
const filteredConfig = discriminatedProviderSettingsWithIdSchema.parse(config)
363+
// For active providers, filter out settings from other providers.
364+
// For retired providers, preserve full profile fields to avoid data loss.
365+
const filteredConfig =
366+
typeof config.apiProvider === "string" && isRetiredProvider(config.apiProvider)
367+
? providerSettingsWithIdSchema.parse(config)
368+
: discriminatedProviderSettingsWithIdSchema.parse(config)
364369
providerProfiles.apiConfigs[name] = { ...filteredConfig, id }
365370
await this.store(providerProfiles)
366371
return id
@@ -507,7 +512,14 @@ export class ProviderSettingsManager {
507512
const profiles = providerProfilesSchema.parse(await this.load())
508513
const configs = profiles.apiConfigs
509514
for (const name in configs) {
510-
// Avoid leaking properties from other providers.
515+
const apiProvider = configs[name].apiProvider
516+
517+
if (typeof apiProvider === "string" && isRetiredProvider(apiProvider)) {
518+
// Preserve retired-provider profiles as-is to prevent dropping legacy fields.
519+
continue
520+
}
521+
522+
// Avoid leaking properties from other active providers.
511523
configs[name] = discriminatedProviderSettingsWithIdSchema.parse(configs[name])
512524

513525
// If it has no apiProvider, skip filtering
@@ -607,7 +619,8 @@ export class ProviderSettingsManager {
607619
}
608620

609621
/**
610-
* Sanitizes a provider config by resetting invalid/removed apiProvider values.
622+
* Sanitizes a provider config by resetting unknown apiProvider values.
623+
* Retired providers are preserved.
611624
* This handles cases where a user had a provider selected that was later removed
612625
* from the extension (e.g., "glama").
613626
*/
@@ -618,10 +631,15 @@ export class ProviderSettingsManager {
618631

619632
const config = apiConfig as Record<string, unknown>
620633

621-
// Check if apiProvider is set and if it's still valid
622-
if (config.apiProvider !== undefined && !isProviderName(config.apiProvider)) {
634+
const apiProvider = config.apiProvider
635+
636+
// Check if apiProvider is set and if it's still recognized (active or retired)
637+
if (
638+
apiProvider !== undefined &&
639+
(typeof apiProvider !== "string" || (!isProviderName(apiProvider) && !isRetiredProvider(apiProvider)))
640+
) {
623641
console.log(
624-
`[ProviderSettingsManager] Sanitizing invalid provider "${config.apiProvider}" - resetting to undefined`,
642+
`[ProviderSettingsManager] Sanitizing unknown provider "${config.apiProvider}" - resetting to undefined`,
625643
)
626644
// Return a new config object without the invalid apiProvider
627645
// This effectively resets the profile so the user can select a valid provider

src/core/config/__tests__/ContextProxy.spec.ts

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ describe("ContextProxy", () => {
424424

425425
it("should reinitialize caches after reset", async () => {
426426
// Spy on initialization methods
427-
const initializeSpy = vi.spyOn(proxy as any, "initialize")
427+
const initializeSpy = vi.spyOn(proxy, "initialize")
428428

429429
// Reset all state
430430
await proxy.resetAllState()
@@ -452,6 +452,25 @@ describe("ContextProxy", () => {
452452
expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", undefined)
453453
})
454454

455+
it("should not clear retired apiProvider from storage during initialization", async () => {
456+
// Reset and create a new proxy with retired provider in state
457+
vi.clearAllMocks()
458+
mockGlobalState.get.mockImplementation((key: string) => {
459+
if (key === "apiProvider") {
460+
return "groq" // Retired provider
461+
}
462+
return undefined
463+
})
464+
465+
const proxyWithRetiredProvider = new ContextProxy(mockContext)
466+
await proxyWithRetiredProvider.initialize()
467+
468+
// Should NOT have called update for apiProvider (retired should be preserved)
469+
const updateCalls = mockGlobalState.update.mock.calls
470+
const apiProviderUpdateCalls = updateCalls.filter((call: unknown[]) => call[0] === "apiProvider")
471+
expect(apiProviderUpdateCalls).toHaveLength(0)
472+
})
473+
455474
it("should not modify valid apiProvider during initialization", async () => {
456475
// Reset and create a new proxy with valid provider in state
457476
vi.clearAllMocks()
@@ -467,25 +486,52 @@ describe("ContextProxy", () => {
467486

468487
// Should NOT have called update for apiProvider (it's valid)
469488
const updateCalls = mockGlobalState.update.mock.calls
470-
const apiProviderUpdateCalls = updateCalls.filter((call: any[]) => call[0] === "apiProvider")
489+
const apiProviderUpdateCalls = updateCalls.filter((call: unknown[]) => call[0] === "apiProvider")
471490
expect(apiProviderUpdateCalls.length).toBe(0)
472491
})
473492
})
474493

475494
describe("getProviderSettings", () => {
476495
it("should sanitize invalid apiProvider before parsing", async () => {
477-
// Set an invalid provider in state
478-
await proxy.updateGlobalState("apiProvider", "invalid-removed-provider" as any)
479-
await proxy.updateGlobalState("apiModelId", "some-model")
496+
// Reset and create a new proxy with an unknown provider in state
497+
vi.clearAllMocks()
498+
mockGlobalState.get.mockImplementation((key: string) => {
499+
if (key === "apiProvider") {
500+
return "invalid-removed-provider"
501+
}
502+
if (key === "apiModelId") {
503+
return "some-model"
504+
}
505+
return undefined
506+
})
480507

481-
const settings = proxy.getProviderSettings()
508+
const proxyWithInvalidProvider = new ContextProxy(mockContext)
509+
await proxyWithInvalidProvider.initialize()
510+
511+
const settings = proxyWithInvalidProvider.getProviderSettings()
482512

483513
// The invalid apiProvider should be sanitized (removed)
484514
expect(settings.apiProvider).toBeUndefined()
485515
// Other settings should still be present
486516
expect(settings.apiModelId).toBe("some-model")
487517
})
488518

519+
it("should preserve retired apiProvider and provider fields", async () => {
520+
await proxy.setValues({
521+
apiProvider: "groq",
522+
apiModelId: "llama3-70b",
523+
openAiBaseUrl: "https://api.retired-provider.example/v1",
524+
apiKey: "retired-provider-key",
525+
})
526+
527+
const settings = proxy.getProviderSettings()
528+
529+
expect(settings.apiProvider).toBe("groq")
530+
expect(settings.apiModelId).toBe("llama3-70b")
531+
expect(settings.openAiBaseUrl).toBe("https://api.retired-provider.example/v1")
532+
expect(settings.apiKey).toBe("retired-provider-key")
533+
})
534+
489535
it("should pass through valid apiProvider", async () => {
490536
// Set a valid provider in state
491537
await proxy.updateGlobalState("apiProvider", "anthropic")

0 commit comments

Comments
 (0)