From 6d54016292b2b9b5ff7a570bcc85ed30376fd927 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Fri, 10 Jul 2026 03:19:19 +0800 Subject: [PATCH] fix(providers): recover from partial sponsor updates --- .../providers/ProvidersWorkbenchPage.tsx | 9 ++++ .../components/SponsorQuickStartPanel.tsx | 5 +++ .../sheets/forms/SponsorProviderForm.tsx | 9 +++- .../providers/sponsorMutationRecovery.ts | 29 +++++++++++++ .../providers/useProviderWorkbench.ts | 42 +++++++++++-------- src/i18n/locales/en.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-TW.json | 1 + tests/sponsorMutationRecovery.test.ts | 40 ++++++++++++++++++ 10 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 src/features/providers/sponsorMutationRecovery.ts create mode 100644 tests/sponsorMutationRecovery.test.ts diff --git a/src/features/providers/ProvidersWorkbenchPage.tsx b/src/features/providers/ProvidersWorkbenchPage.tsx index 2c1f9c7b..6cc5a766 100644 --- a/src/features/providers/ProvidersWorkbenchPage.tsx +++ b/src/features/providers/ProvidersWorkbenchPage.tsx @@ -19,6 +19,7 @@ import { SponsorQuickStartPanel } from './components/SponsorQuickStartPanel'; import { ProviderSheet, type ProviderSheetHandle } from './sheets/ProviderSheet'; import { APIKEY_FUN_DISPLAY_NAME } from './sponsor'; import { isMultiProtocolSponsorBrand } from './sponsorDefinitions'; +import { isSponsorPartialMutationError } from './sponsorMutationRecovery'; import { useProviderWorkbench } from './useProviderWorkbench'; import { getProviderFilterState, @@ -326,6 +327,10 @@ export function ProvidersWorkbenchPage({ fixedBrand }: ProvidersWorkbenchPagePro await workbench.deleteProvider(resource); showNotification(t('providersPage.toast.deleted'), 'success'); } catch (err) { + if (isSponsorPartialMutationError(err)) { + showNotification(t('providersPage.sponsor.partialMutationWarning'), 'warning'); + return; + } const msg = err instanceof Error ? err.message : String(err); showNotification(`${t('notification.delete_failed')}: ${msg}`, 'error'); } @@ -344,6 +349,10 @@ export function ProvidersWorkbenchPage({ fixedBrand }: ProvidersWorkbenchPagePro 'success' ); } catch (err) { + if (isSponsorPartialMutationError(err)) { + showNotification(t('providersPage.sponsor.partialMutationWarning'), 'warning'); + return; + } const msg = err instanceof Error ? err.message : String(err); showNotification(`${t('providersPage.toast.toggleFailed')}: ${msg}`, 'error'); } diff --git a/src/features/providers/components/SponsorQuickStartPanel.tsx b/src/features/providers/components/SponsorQuickStartPanel.tsx index 0291b28c..e2ba4a61 100644 --- a/src/features/providers/components/SponsorQuickStartPanel.tsx +++ b/src/features/providers/components/SponsorQuickStartPanel.tsx @@ -5,6 +5,7 @@ import { useNotificationStore } from '@/stores'; import { IconCheckCircle2, IconExternalLink, IconLoader2, IconPlus } from '@/components/ui/icons'; import { PROVIDER_LOGOS } from '../brandLogos'; import { APIKEY_FUN_AFFILIATE_URL, APIKEY_FUN_DASHBOARD_URL } from '../sponsor'; +import { isSponsorPartialMutationError } from '../sponsorMutationRecovery'; import type { ProviderEntryFormInput, ProviderResource } from '../types'; import type { UseProviderWorkbenchResult } from '../useProviderWorkbench'; import { SponsorProviderForm } from '../sheets/forms/SponsorProviderForm'; @@ -61,6 +62,10 @@ export function SponsorQuickStartPanel({ setIsDirty(false); setFormVersion((current) => current + 1); } catch (err) { + if (isSponsorPartialMutationError(err)) { + showNotification(t('providersPage.sponsor.partialMutationWarning'), 'warning'); + throw err; + } const msg = err instanceof Error ? err.message : String(err); showNotification( `${t(resource ? 'notification.update_failed' : 'notification.add_failed')}: ${msg}`, diff --git a/src/features/providers/sheets/forms/SponsorProviderForm.tsx b/src/features/providers/sheets/forms/SponsorProviderForm.tsx index bf08187a..1d1a18f2 100644 --- a/src/features/providers/sheets/forms/SponsorProviderForm.tsx +++ b/src/features/providers/sheets/forms/SponsorProviderForm.tsx @@ -18,6 +18,7 @@ import { hasDisableAllModelsRule } from '@/components/providers/utils'; import { maskApiKey } from '@/utils/format'; import type { ModelInfo } from '@/utils/models'; import type { ApiKeyFunUsageSummary } from '../../sponsor'; +import { isSponsorPartialMutationError } from '../../sponsorMutationRecovery'; import { discoveryBrandForSponsorProtocol, getSponsorAggregationConflict, @@ -837,7 +838,13 @@ export function SponsorProviderForm({ setError(null); await onSubmit({ ...form, sponsorKeyEntries: entries }); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + setError( + isSponsorPartialMutationError(err) + ? t('providersPage.sponsor.partialMutationWarning') + : err instanceof Error + ? err.message + : String(err) + ); } }; diff --git a/src/features/providers/sponsorMutationRecovery.ts b/src/features/providers/sponsorMutationRecovery.ts new file mode 100644 index 00000000..7e60cca3 --- /dev/null +++ b/src/features/providers/sponsorMutationRecovery.ts @@ -0,0 +1,29 @@ +export class SponsorPartialMutationError extends Error { + readonly cause: unknown; + + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : String(cause ?? 'Sponsor mutation failed')); + this.name = 'SponsorPartialMutationError'; + this.cause = cause; + } +} + +export const isSponsorPartialMutationError = ( + error: unknown +): error is SponsorPartialMutationError => error instanceof SponsorPartialMutationError; + +export async function runSponsorMutationWithRecovery( + action: () => Promise, + refresh: () => Promise +): Promise { + try { + return await action(); + } catch (error: unknown) { + try { + await refresh(); + } catch { + // Preserve the original mutation error; refresh is best-effort recovery. + } + throw new SponsorPartialMutationError(error); + } +} diff --git a/src/features/providers/useProviderWorkbench.ts b/src/features/providers/useProviderWorkbench.ts index 5adfaae5..58c74487 100644 --- a/src/features/providers/useProviderWorkbench.ts +++ b/src/features/providers/useProviderWorkbench.ts @@ -59,6 +59,7 @@ import { isQiniuCloudOpenAIProvider, } from './qiniuCloud'; import { getSponsorProviderDefinition, type SponsorProtocolUrls } from './sponsorDefinitions'; +import { runSponsorMutationWithRecovery } from './sponsorMutationRecovery'; export interface UseProviderWorkbenchResult { connected: boolean; @@ -662,7 +663,7 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult { brand === 'fennoAI' || brand === 'qiniuCloud' ) { - await persistSponsorConfig(brand, input); + await runSponsorMutationWithRecovery(() => persistSponsorConfig(brand, input), refetch); } await refetch(); } finally { @@ -724,7 +725,7 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult { brand === 'fennoAI' || brand === 'qiniuCloud' ) { - await persistSponsorConfig(brand, input); + await runSponsorMutationWithRecovery(() => persistSponsorConfig(brand, input), refetch); } await refetch(); } finally { @@ -769,21 +770,23 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult { sel.brand === 'fennoAI' || sel.brand === 'qiniuCloud' ) { - const raw = resource.raw as SponsorProviderRaw; - for (const item of raw.gemini) { - await providersApi.deleteGeminiKey(item.config.apiKey, item.config.baseUrl); - } - for (const item of raw.codex) { - await providersApi.deleteCodexConfig(item.config.apiKey, item.config.baseUrl); - } - for (const item of raw.claude) { - await providersApi.deleteClaudeConfig(item.config.apiKey, item.config.baseUrl); - } - const openAINames = new Set(raw.openai.map((item) => item.config.name)); - for (const name of openAINames) { - const item = raw.openai.find((candidate) => candidate.config.name === name); - if (item) await providersApi.deleteOpenAIProvidersByName(name); - } + await runSponsorMutationWithRecovery(async () => { + const raw = resource.raw as SponsorProviderRaw; + for (const item of raw.gemini) { + await providersApi.deleteGeminiKey(item.config.apiKey, item.config.baseUrl); + } + for (const item of raw.codex) { + await providersApi.deleteCodexConfig(item.config.apiKey, item.config.baseUrl); + } + for (const item of raw.claude) { + await providersApi.deleteClaudeConfig(item.config.apiKey, item.config.baseUrl); + } + const openAINames = new Set(raw.openai.map((item) => item.config.name)); + for (const name of openAINames) { + const item = raw.openai.find((candidate) => candidate.config.name === name); + if (item) await providersApi.deleteOpenAIProvidersByName(name); + } + }, refetch); } await refetch(); } finally { @@ -834,7 +837,10 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult { brand === 'fennoAI' || brand === 'qiniuCloud' ) { - await toggleSponsorConfig(resource.raw as SponsorProviderRaw, disabled); + await runSponsorMutationWithRecovery( + () => toggleSponsorConfig(resource.raw as SponsorProviderRaw, disabled), + refetch + ); } await refetch(); } finally { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 32b723b7..2cf34ad9 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1359,6 +1359,7 @@ "usageEmpty": "The endpoint returned no usage data.", "usageApiKeyRequired": "API key is required", "aggregationConflict": "This grouped provider contains multiple backend configs or OpenAI keys. Edit it in the source configuration to avoid data loss.", + "partialMutationWarning": "The operation failed after one or more protocols may have been updated. The latest configuration was reloaded; review it before retrying.", "groupedKeysTitle": "Grouped keys", "groupedKeysHint": "Platform keys may be bound to a group when created. Each grouped key here manages one protocol, and model discovery only uses that protocol.", "groupedKey": "Grouped key #{{index}}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 4d5aa476..816da9d0 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1337,6 +1337,7 @@ "usageEmpty": "Endpoint не вернул данные квоты.", "usageApiKeyRequired": "Требуется API key", "aggregationConflict": "Этот объединённый провайдер содержит несколько серверных конфигураций или ключей OpenAI. Чтобы избежать потери данных, измените исходную конфигурацию.", + "partialMutationWarning": "Операция завершилась ошибкой, но один или несколько протоколов могли обновиться. Последняя конфигурация загружена повторно; проверьте её перед повторной попыткой.", "groupedKeysTitle": "Групповые ключи", "groupedKeysHint": "Ключи платформы могут привязываться к группе при создании. Каждый групповой ключ здесь управляет одним протоколом, а модели загружаются только из этого протокола.", "groupedKey": "Групповой ключ #{{index}}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0d6b6649..51594db4 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1359,6 +1359,7 @@ "usageEmpty": "端点未返回额度数据", "usageApiKeyRequired": "API 密钥必填", "aggregationConflict": "此聚合提供商包含多条后端配置或多个 OpenAI 密钥。为避免数据丢失,请在源码配置中编辑。", + "partialMutationWarning": "操作失败,但一个或多个协议可能已更新。已重新读取最新配置,请检查后再重试。", "groupedKeysTitle": "分组 Key", "groupedKeysHint": "平台创建 Key 时可能需要选择分组,这里每个分组 Key 只管理一个协议,模型也只从该协议拉取。", "groupedKey": "分组 Key #{{index}}", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index 7e379ad0..424d8e5b 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1385,6 +1385,7 @@ "usageEmpty": "端點未回傳額度資料", "usageApiKeyRequired": "API 金鑰必填", "aggregationConflict": "此聚合提供商包含多筆後端設定或多個 OpenAI 金鑰。為避免資料遺失,請在原始碼設定中編輯。", + "partialMutationWarning": "操作失敗,但一個或多個協議可能已更新。已重新讀取最新設定,請檢查後再重試。", "groupedKeysTitle": "分組金鑰", "groupedKeysHint": "平台建立 Key 時可能需要選擇分組,這裡每個分組金鑰只管理一個協議,模型也只從該協議拉取。", "groupedKey": "分組金鑰 #{{index}}", diff --git a/tests/sponsorMutationRecovery.test.ts b/tests/sponsorMutationRecovery.test.ts new file mode 100644 index 00000000..9e0cbe4a --- /dev/null +++ b/tests/sponsorMutationRecovery.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, mock, test } from 'bun:test'; +import { + isSponsorPartialMutationError, + runSponsorMutationWithRecovery, +} from '../src/features/providers/sponsorMutationRecovery'; + +describe('sponsor mutation recovery', () => { + test('refreshes after a failed multi-endpoint mutation and preserves the original failure', async () => { + const originalError = new Error('Claude update failed'); + const refresh = mock(async () => {}); + + let caught: unknown; + try { + await runSponsorMutationWithRecovery(async () => { + throw originalError; + }, refresh); + } catch (error) { + caught = error; + } + + expect(refresh).toHaveBeenCalledTimes(1); + expect(isSponsorPartialMutationError(caught)).toBe(true); + expect((caught as Error & { cause?: unknown }).cause).toBe(originalError); + }); + + test('does not let a refresh failure replace the original mutation failure', async () => { + const originalError = new Error('OpenAI update failed'); + + await expect( + runSponsorMutationWithRecovery( + async () => { + throw originalError; + }, + async () => { + throw new Error('refresh failed'); + } + ) + ).rejects.toMatchObject({ cause: originalError }); + }); +});