From 224a076ba741e98136926fda6e6789ff24ad119f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 30 Jul 2026 19:36:11 +0800 Subject: [PATCH] =?UTF-8?q?chore(sponsor):=20=E4=B8=8B=E7=BA=BF=E8=83=9C?= =?UTF-8?q?=E7=AE=97=E4=BA=91=E8=B5=9E=E5=8A=A9=EF=BC=8C=E9=80=80=E5=BD=B9?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E6=94=B9=E4=B8=BA=20deprecated=20=E5=A2=93?= =?UTF-8?q?=E7=A2=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 胜算云不再赞助,从 README 与「添加服务商」入口下架。与 558124914 下线接口AI 的做法不同,这次不删预设条目——纯删会静默降级已配置该预设的 provider: - defaultEnv 从不随 provider 持久化,每次运行都从预设重算(胜算云的 API_TIMEOUT_MS、CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC 与 ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES=none) - getManagedEnvKeys() 用所有预设的 defaultEnv 键构建 settings.json 擦除清单, 而 API_TIMEOUT_MS 与 CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC 仅由胜算云声明, 删掉后这两个键永久脱离清单,陈旧值会泄漏进此后激活的每个 provider - provider 卡片 Badge 渲染预设名,预设消失则已配置的卡片标签消失 - 较早的存档可能连 authStrategy / modelContextWindows 都未持久化,同样回退到预设 改动: - providerPresets.json:胜算云标 deprecated,摘掉 featured / promoText / apiKeyUrl; 一并补回 jiekouai 墓碑条目,修掉 558124914 留下的同类回归(该提交未进任何 tag, 回归尚未发布) - 过滤只加在新增路径:桌面端预设 chips 源,以及粘贴 settings JSON 后按 ANTHROPIC_BASE_URL 自动切预设那处;按 presetId 反查的路径一律保持全量 - GET /api/providers/presets 仍返回全量并携带 deprecated 标记,由消费方自行过滤 - ccSwitchImport 的 matchPresetId 有意继续匹配退役预设:导入映射的是用户既有配置, 继承该预设的 env 比落到 custom 更正确 - README 中英文移除胜算云赞助整行与推广链接,删除 shengsuanyun-logo.svg, 中英文 docs 模型页的中转服务商清单去掉胜算云 测试:settings.json 擦除清单不变量、退役预设仍解析出完整运行时 env、退役预设不得 残留推广字段、接口AI 的第三方 Sonnet 护栏(capabilities=none 与 1M 上下文窗口)、 桌面端 selectableProviderPresets 过滤。 --- README.md | 10 --- README.zh-CN.md | 10 --- desktop/src/config/providerPresets.test.ts | 71 +++++++++++++++++++ desktop/src/config/providerPresets.ts | 11 +++ desktop/src/pages/Settings.tsx | 15 ++-- desktop/src/types/providerPreset.ts | 2 + docs/en/start/models.md | 2 +- docs/images/sponsors/shengsuanyun-logo.svg | 4 -- docs/start/models.md | 2 +- src/server/__tests__/provider-presets.test.ts | 44 +++++++++++- .../__tests__/provider-runtime-env.test.ts | 58 +++++++++++++++ src/server/api/providers.ts | 2 + src/server/config/providerPresets.json | 29 +++++++- src/server/config/providerPresets.ts | 12 ++++ src/server/services/ccSwitchImport.ts | 2 + 15 files changed, 237 insertions(+), 37 deletions(-) create mode 100644 desktop/src/config/providerPresets.test.ts delete mode 100644 docs/images/sponsors/shengsuanyun-logo.svg diff --git a/README.md b/README.md index 6c651957..7e16aff6 100644 --- a/README.md +++ b/README.md @@ -121,16 +121,6 @@ This project is maintained in the author's spare time. Corporate or individual s - - - - ShengSuanYun - - - - Thanks to ShengSuanYun for sponsoring this project. ShengSuanYun is an industrial-grade AI task parallel execution platform for AI Native Teams, aggregating Claude, ChatGPT, Gemini, and other LLM, image, and video model capacity through direct, non-reverse-engineered access. Its platform SLA reaches 99.7%, with service status available online. It also supports dedicated enterprise gateways, cost and permission controls, smart routing, security protection, BYOK, usage-based billing, upcoming tokens plans, and invoicing. New users registering through this link can receive 10 yuan in model credits plus a 10% first top-up bonus. - - diff --git a/README.zh-CN.md b/README.zh-CN.md index 1c7496c6..2c27be4a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -121,16 +121,6 @@ cp .env.example .env - - - - 胜算云 - - - - 感谢 胜算云 赞助本项目!胜算云是面向 AI Native Teams 的工业级 AI 任务并行执行平台,聚合 Claude、ChatGPT、Gemini 等海内外 LLM 及图片、视频多媒体模型算力;官方直连、非逆向,平台 SLA 可用性达 99.7%,可查看 服务状态。平台支持企业专属网关、成本与权限管控、智能路由、安全防护和 BYOK,按量与 tokens plan(即将上线)计费并可开票;使用 专属链接 注册可获 10 元模力及首充 10% 赠送。 - - diff --git a/desktop/src/config/providerPresets.test.ts b/desktop/src/config/providerPresets.test.ts new file mode 100644 index 00000000..be623778 --- /dev/null +++ b/desktop/src/config/providerPresets.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' + +import { BUNDLED_PROVIDER_PRESETS, selectableProviderPresets } from './providerPresets' +import type { ProviderPreset } from '../types/providerPreset' + +function makePreset(overrides: Partial & { id: string }): ProviderPreset { + return { + name: overrides.id, + baseUrl: `https://example.test/${overrides.id}`, + apiFormat: 'anthropic', + defaultModels: { main: 'm', haiku: 'm', sonnet: 'm', opus: 'm' }, + needsApiKey: true, + websiteUrl: '', + ...overrides, + } +} + +describe('selectableProviderPresets', () => { + it('drops retired presets and keeps the rest in order', () => { + const presets = [ + makePreset({ id: 'keep-a' }), + makePreset({ id: 'retired', deprecated: true }), + makePreset({ id: 'keep-b' }), + ] + + expect(selectableProviderPresets(presets).map((preset) => preset.id)).toEqual([ + 'keep-a', + 'keep-b', + ]) + }) + + it('keeps presets that explicitly opt out of retirement', () => { + const presets = [makePreset({ id: 'live', deprecated: false })] + + expect(selectableProviderPresets(presets)).toHaveLength(1) + }) +}) + +describe('bundled provider presets', () => { + // Retiring a preset must not break providers already configured against it: the + // bundle keeps the entry so the edit form can still resolve its presetId, while + // the "add provider" chips are built from selectableProviderPresets. + it('keeps the retired 胜算云 preset resolvable but not selectable', () => { + const shengsuanyun = BUNDLED_PROVIDER_PRESETS.find((preset) => preset.id === 'shengsuanyun') + + expect(shengsuanyun?.deprecated).toBe(true) + expect(shengsuanyun?.baseUrl).toBe('https://router.shengsuanyun.com/api') + expect(shengsuanyun?.authStrategy).toBe('auth_token') + // No referral link or promo copy left to render while editing. + expect(shengsuanyun?.apiKeyUrl).toBeUndefined() + expect(shengsuanyun?.promoText).toBeUndefined() + expect(shengsuanyun?.featured).toBeUndefined() + + const selectableIds = selectableProviderPresets(BUNDLED_PROVIDER_PRESETS).map((p) => p.id) + expect(selectableIds).not.toContain('shengsuanyun') + expect(selectableIds).toContain('teamorouter') + expect(selectableIds).toContain('custom') + }) + + it('keeps the retired 接口AI preset resolvable but not selectable', () => { + const jiekouai = BUNDLED_PROVIDER_PRESETS.find((preset) => preset.id === 'jiekouai') + + expect(jiekouai?.deprecated).toBe(true) + expect(jiekouai?.apiKeyUrl).toBeUndefined() + expect(jiekouai?.promoText).toBeUndefined() + expect(jiekouai?.featured).toBeUndefined() + + expect(selectableProviderPresets(BUNDLED_PROVIDER_PRESETS).map((p) => p.id)) + .not.toContain('jiekouai') + }) +}) diff --git a/desktop/src/config/providerPresets.ts b/desktop/src/config/providerPresets.ts index b5fcee83..437f02b3 100644 --- a/desktop/src/config/providerPresets.ts +++ b/desktop/src/config/providerPresets.ts @@ -3,4 +3,15 @@ import type { ProviderPreset } from '../types/providerPreset' // Presets ship with the desktop bundle. Provider creation must remain available // even when the local HTTP control plane is temporarily unavailable. +// Keeps retired presets so an existing provider's presetId still resolves — the edit +// form reads the preset for its name badge and for any field the record itself never +// persisted (defaultEnv always; authStrategy / modelContextWindows on older records). export const BUNDLED_PROVIDER_PRESETS = providerPresetsJson as ProviderPreset[] + +/** + * Presets a user may pick when adding a provider. Retired ones are dropped here + * rather than deleted from the bundle, so already-configured providers are untouched. + */ +export function selectableProviderPresets(presets: ProviderPreset[]): ProviderPreset[] { + return presets.filter((preset) => !preset.deprecated) +} diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index a7622a8d..ad95d8f3 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -43,6 +43,7 @@ import type { Locale } from '../i18n' import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, Model1mSupport, ApiFormat, ProviderAuthStrategy, ProviderModelInfo, ProviderModelsErrorCode } from '../types/provider' import { groupProviderModels, providerModelsErrorKey } from '../lib/providerModels' import type { ProviderPreset } from '../types/providerPreset' +import { selectableProviderPresets } from '../config/providerPresets' import { AdapterSettings } from './AdapterSettings' import { useSessionStore } from '../stores/sessionStore' import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer' @@ -1280,16 +1281,20 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF const fallbackPreset = buildFallbackPreset(provider) const loadedPresets = presets.filter((p) => p.id !== 'official') + // Keeps retired presets, so editing a provider already saved against one still + // resolves the preset behind its presetId instead of falling back. const availablePresets = loadedPresets.length > 0 ? loadedPresets : [fallbackPreset] - const regularPresets = availablePresets.filter((p) => !p.featured) - const featuredPresets = availablePresets.filter((p) => p.featured) + // Retired presets must never be offered when adding a provider. + const selectablePresets = selectableProviderPresets(availablePresets) + const regularPresets = selectablePresets.filter((p) => !p.featured) + const featuredPresets = selectablePresets.filter((p) => p.featured) const presetDefaultEnvKeys = useMemo( () => presets.flatMap((preset) => Object.keys(preset.defaultEnv ?? {})), [presets], ) const initialPreset = provider ? availablePresets.find((p) => p.id === provider.presetId) ?? fallbackPreset - : availablePresets[0] ?? fallbackPreset + : selectablePresets[0] ?? fallbackPreset const initialModels = stripModel1mMarkers(provider?.models ?? initialPreset.defaultModels) const initialModel1mSupport = getInitialModel1mSupport( provider?.models ?? initialPreset.defaultModels, @@ -2133,9 +2138,9 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF setBaseUrl(env.ANTHROPIC_BASE_URL) // Auto-switch to matching preset or Custom if (mode === 'create') { - const matchedPreset = availablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL) + const matchedPreset = selectablePresets.find((p) => p.id !== 'custom' && p.baseUrl === env.ANTHROPIC_BASE_URL) const targetPreset = requirePreset( - matchedPreset ?? availablePresets.find((p) => p.id === 'custom'), + matchedPreset ?? selectablePresets.find((p) => p.id === 'custom'), ) if (targetPreset.id !== selectedPreset.id) { jsonPastedRef.current = true diff --git a/desktop/src/types/providerPreset.ts b/desktop/src/types/providerPreset.ts index 9b0f8727..c29676b9 100644 --- a/desktop/src/types/providerPreset.ts +++ b/desktop/src/types/providerPreset.ts @@ -19,6 +19,8 @@ export type ProviderPreset = { apiKeyUrl?: string promoText?: string featured?: boolean + /** Retired preset: hidden from the "add provider" choices, still resolves saved providers. */ + deprecated?: boolean authStrategy?: ProviderAuthStrategy defaultEnv?: Record modelContextWindows?: Record diff --git a/docs/en/start/models.md b/docs/en/start/models.md index 7f18cf9b..ed7bd853 100644 --- a/docs/en/start/models.md +++ b/docs/en/start/models.md @@ -44,7 +44,7 @@ With an API key in hand, this is the fastest route. Click "Add Provider", pick s The built-in presets, as they appear in the dialog: - **DeepSeek** · **Zhipu GLM** · **Kimi** · **MiniMax** — major Chinese model vendors; the base URLs point at each one's Anthropic-compatible endpoint. -- **胜算云 (Shengsuanyun)** · **TeamoRouter** — routing services that give you access to official Claude models through their own gateway. +- **TeamoRouter** — a routing service that gives you access to official Claude models through its own gateway. - **LM Studio** · **Ollama** — local models; see the next section. - **Custom** — anything not listed above. diff --git a/docs/images/sponsors/shengsuanyun-logo.svg b/docs/images/sponsors/shengsuanyun-logo.svg deleted file mode 100644 index e9ceb5b8..00000000 --- a/docs/images/sponsors/shengsuanyun-logo.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/docs/start/models.md b/docs/start/models.md index b63468de..e1397a2e 100644 --- a/docs/start/models.md +++ b/docs/start/models.md @@ -44,7 +44,7 @@ Claude Code Haha 自己不带模型,它只是那个替你干活的壳。装完 内置预设(按弹窗里的排列): - **DeepSeek** · **Zhipu GLM** · **Kimi** · **MiniMax** — 国内主流模型厂商,接口地址是各家的 Anthropic 兼容端点。 -- **胜算云** · **TeamoRouter** — 中转类服务商,用它们的通道调 Claude 官方模型。 +- **TeamoRouter** — 中转类服务商,用它们的通道调 Claude 官方模型。 - **LM Studio** · **Ollama** — 本地模型,见下一节。 - **Custom** — 上面都没有,自己填。 diff --git a/src/server/__tests__/provider-presets.test.ts b/src/server/__tests__/provider-presets.test.ts index 56c3ec57..800f15bf 100644 --- a/src/server/__tests__/provider-presets.test.ts +++ b/src/server/__tests__/provider-presets.test.ts @@ -158,9 +158,10 @@ describe('provider presets API', () => { expect(kimi?.modelContextWindows?.['kimi-for-coding']).toBe(262144) expect(kimi?.modelContextWindows?.['kimi-for-coding-highspeed']).toBe(262144) expect(minimax?.apiKeyUrl).toBe('https://platform.minimaxi.com/subscribe/token-plan?code=1TG2Cseab2&source=link') - expect(shengsuanyun?.apiKeyUrl).toBe('https://www.shengsuanyun.com/?from=CH_LEJ88KWR') - expect(shengsuanyun?.promoText).toContain('首充 10%') - expect(shengsuanyun?.featured).toBe(true) + // Retired: no referral link, no promo copy, no featured slot. + expect(shengsuanyun?.apiKeyUrl).toBeUndefined() + expect(shengsuanyun?.promoText).toBeUndefined() + expect(shengsuanyun?.featured).toBeUndefined() expect(shengsuanyun?.defaultEnv).toEqual({ API_TIMEOUT_MS: '3000000', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', @@ -215,6 +216,43 @@ describe('provider presets API', () => { expect(JSON.parse(updatedRaw)).toEqual(updateBody) }) + test('retired presets keep the runtime config saved providers resolve from them', () => { + for (const id of ['shengsuanyun', 'jiekouai']) { + const preset = PROVIDER_PRESETS.find((candidate) => candidate.id === id) + + expect(preset?.deprecated).toBe(true) + // defaultEnv is never persisted per provider, so it can only come from here. + expect(preset?.defaultEnv).toBeDefined() + // Records saved before these fields existed fall back to the preset for them. + expect(preset?.authStrategy).toBe('auth_token') + expect(preset?.modelContextWindows).toBeDefined() + } + }) + + test('retired 接口AI preset keeps the third-party Sonnet guards it was retired with', () => { + const jiekouai = PROVIDER_PRESETS.find((preset) => preset.id === 'jiekouai') + + // Dropping this flips modelSupportsThinking('claude-sonnet-4-6') to true, which makes + // the CLI send thinking on a provider that was configured not to accept it. + expect(jiekouai?.defaultEnv?.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe('none') + // Dropping this silently collapses the context window 1M -> 200k. + expect(jiekouai?.modelContextWindows?.['claude-sonnet-4-6']).toBe(1000000) + expect(jiekouai?.modelContextWindows?.['claude-opus-4-7']).toBe(1000000) + }) + + test('retired presets stop promoting themselves', () => { + const retired = PROVIDER_PRESETS.filter((preset) => preset.deprecated) + expect(retired.length).toBeGreaterThan(0) + + for (const preset of retired) { + // The edit form renders apiKeyUrl/promoText from the resolved preset with no mode + // guard, so a retired preset must not keep advertising itself to existing users. + expect(preset.apiKeyUrl).toBeUndefined() + expect(preset.promoText).toBeUndefined() + expect(preset.featured).toBeUndefined() + } + }) + test('provider presets carry docs-backed context windows for current coding models', () => { const byId = new Map(PROVIDER_PRESETS.map((preset) => [preset.id, preset])) diff --git a/src/server/__tests__/provider-runtime-env.test.ts b/src/server/__tests__/provider-runtime-env.test.ts index f8101283..8d298cee 100644 --- a/src/server/__tests__/provider-runtime-env.test.ts +++ b/src/server/__tests__/provider-runtime-env.test.ts @@ -4,6 +4,7 @@ import * as os from 'os' import * as path from 'path' import { + getManagedEnvKeys, mergeActiveProviderManagedEnv, readActiveProviderManagedEnv, } from '../services/providerRuntimeEnv.js' @@ -404,6 +405,63 @@ describe('providerRuntimeEnv', () => { 'glm-4.7': 200000, }) }) + + // getManagedEnvKeys() is the erase list used to strip stale provider env out of + // cc-haha/settings.json. It is built by unioning every preset's defaultEnv keys, so + // deleting a preset outright would drop keys only that preset declares — they would + // then never be cleaned and would leak into every provider activated afterwards. + test('keeps the settings.json erase list covering retired presets env keys', () => { + const keys = getManagedEnvKeys() + + // Declared only by the retired 胜算云 preset; a stale 50-minute API_TIMEOUT_MS + // leaking into other providers is exactly what this guards. + expect(keys).toContain('API_TIMEOUT_MS') + expect(keys).toContain('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC') + }) + + // Retiring a preset must not touch providers already saved against it. defaultEnv is + // never persisted per provider — it is resolved from the preset on every run, so + // deleting the entry would silently drop it. Older records may also lack + // authStrategy / modelContextWindows and fall back to the preset for those too. + test('keeps resolving preset runtime env for providers saved against a retired preset', async () => { + await writeJson(path.join(tmpDir, 'cc-haha', 'providers.json'), { + activeId: 'provider-shengsuanyun', + providers: [ + { + id: 'provider-shengsuanyun', + presetId: 'shengsuanyun', + name: '胜算云', + apiKey: 'sk-shengsuanyun', + baseUrl: 'https://router.shengsuanyun.com/api', + apiFormat: 'anthropic', + models: { + main: 'anthropic/claude-sonnet-4.6', + haiku: 'anthropic/claude-haiku-4.5:thinking', + sonnet: 'anthropic/claude-sonnet-4.6', + opus: 'anthropic/claude-opus-4.7', + }, + }, + ], + }) + + const env = readActiveProviderManagedEnv(tmpDir) + + expect(env).toMatchObject({ + ANTHROPIC_BASE_URL: 'https://router.shengsuanyun.com/api', + ANTHROPIC_MODEL: 'anthropic/claude-sonnet-4.6', + // preset defaultEnv survives the retirement + API_TIMEOUT_MS: '3000000', + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', + ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES: 'none', + }) + // preset authStrategy (auth_token) survives: bearer token, blanked api key + expect(env?.ANTHROPIC_AUTH_TOKEN).toBe('sk-shengsuanyun') + expect(env?.ANTHROPIC_API_KEY).toBe('') + expect(JSON.parse(env!.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toMatchObject({ + 'anthropic/claude-sonnet-4.6': 1000000, + 'anthropic/claude-opus-4.7': 1000000, + }) + }) }) function clearCapabilityCache() { diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts index ff1928a3..dd3361ff 100644 --- a/src/server/api/providers.ts +++ b/src/server/api/providers.ts @@ -85,6 +85,8 @@ export async function handleProvidersApi( } // GET /api/providers/presets + // Carries retired presets too (flagged `deprecated`), so a consumer resolving a saved + // provider's presetId still finds it. Filter on `deprecated` to build add-provider choices. if (id === 'presets' && req.method === 'GET') { return Response.json({ presets: PROVIDER_PRESETS }) } diff --git a/src/server/config/providerPresets.json b/src/server/config/providerPresets.json index e026de3e..9bdae09e 100644 --- a/src/server/config/providerPresets.json +++ b/src/server/config/providerPresets.json @@ -128,6 +128,31 @@ "MiniMax-M2.7-highspeed": 204800 } }, + { + "id": "jiekouai", + "name": "接口AI", + "baseUrl": "https://api.jiekou.ai/anthropic", + "apiFormat": "anthropic", + "defaultModels": { + "main": "claude-sonnet-4-6", + "haiku": "claude-haiku-4-5-20251001", + "sonnet": "claude-sonnet-4-6", + "opus": "claude-opus-4-7" + }, + "needsApiKey": true, + "websiteUrl": "https://jiekou.ai", + "deprecated": true, + "authStrategy": "auth_token", + "defaultEnv": { + "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": "none" + }, + "modelContextWindows": { + "claude-sonnet-4-6": 1000000, + "claude-haiku-4-5-20251001": 200000, + "claude-haiku-4-5": 200000, + "claude-opus-4-7": 1000000 + } + }, { "id": "shengsuanyun", "name": "胜算云", @@ -141,9 +166,7 @@ }, "needsApiKey": true, "websiteUrl": "https://www.shengsuanyun.com", - "apiKeyUrl": "https://www.shengsuanyun.com/?from=CH_LEJ88KWR", - "promoText": "胜算云为 cc-haha 的用户提供了特别福利,使用此链接注册的新用户可获 10 元模力及首充 10% 赠送!", - "featured": true, + "deprecated": true, "authStrategy": "auth_token", "defaultEnv": { "API_TIMEOUT_MS": "3000000", diff --git a/src/server/config/providerPresets.ts b/src/server/config/providerPresets.ts index ef2d7e42..e453afe3 100644 --- a/src/server/config/providerPresets.ts +++ b/src/server/config/providerPresets.ts @@ -25,6 +25,17 @@ const ProviderPresetSchema = z.object({ apiKeyUrl: z.string().optional(), promoText: z.string().optional(), featured: z.boolean().optional(), + // Retired sponsor/provider: filtered out of the "add provider" choices, but the entry + // MUST stay in this list — deleting it silently degrades providers already saved + // against it, because three things are resolved from the preset, never from the + // provider record: + // 1. defaultEnv — never persisted per provider, re-resolved on every run + // 2. getManagedEnvKeys() — builds the settings.json erase list from every preset's + // defaultEnv keys, so dropping a preset leaks its keys into other providers + // 3. the provider card badge, which renders the preset's name + // Older records may also lack authStrategy / modelContextWindows entirely and fall + // back to the preset for those too. + deprecated: z.boolean().optional(), authStrategy: ProviderAuthStrategySchema.optional(), defaultEnv: z.record(z.string(), z.string()).optional(), modelContextWindows: z.record( @@ -38,4 +49,5 @@ const ProviderPresetsSchema = z.array(ProviderPresetSchema) export type ModelMapping = z.infer export type ProviderPreset = z.infer +/** Every preset, including retired ones — use this to resolve a saved provider's presetId. */ export const PROVIDER_PRESETS = ProviderPresetsSchema.parse(providerPresetsJson) diff --git a/src/server/services/ccSwitchImport.ts b/src/server/services/ccSwitchImport.ts index 4ee97b5b..350bf1c0 100644 --- a/src/server/services/ccSwitchImport.ts +++ b/src/server/services/ccSwitchImport.ts @@ -670,6 +670,8 @@ function matchPresetId(baseUrl: string): string { const normalized = normalizeBaseUrl(baseUrl) if (!normalized) return 'custom' // Presets without a base URL (official, custom) can never be matched by URL. + // Retired (`deprecated`) presets are matched on purpose: an import maps a config the + // user already has elsewhere, so inheriting that preset's env beats landing on custom. const preset = PROVIDER_PRESETS.find( (candidate) => candidate.baseUrl && normalizeBaseUrl(candidate.baseUrl) === normalized, )