fix(providers): recover from partial sponsor updates

This commit is contained in:
Supra4E8C
2026-07-10 03:19:19 +08:00
parent 2cd2de7719
commit 6d54016292
10 changed files with 119 additions and 19 deletions
@@ -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');
}
@@ -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}`,
@@ -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)
);
}
};
@@ -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<T>(
action: () => Promise<T>,
refresh: () => Promise<unknown>
): Promise<T> {
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);
}
}
+24 -18
View File
@@ -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 {
+1
View File
@@ -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}}",
+1
View File
@@ -1337,6 +1337,7 @@
"usageEmpty": "Endpoint не вернул данные квоты.",
"usageApiKeyRequired": "Требуется API key",
"aggregationConflict": "Этот объединённый провайдер содержит несколько серверных конфигураций или ключей OpenAI. Чтобы избежать потери данных, измените исходную конфигурацию.",
"partialMutationWarning": "Операция завершилась ошибкой, но один или несколько протоколов могли обновиться. Последняя конфигурация загружена повторно; проверьте её перед повторной попыткой.",
"groupedKeysTitle": "Групповые ключи",
"groupedKeysHint": "Ключи платформы могут привязываться к группе при создании. Каждый групповой ключ здесь управляет одним протоколом, а модели загружаются только из этого протокола.",
"groupedKey": "Групповой ключ #{{index}}",
+1
View File
@@ -1359,6 +1359,7 @@
"usageEmpty": "端点未返回额度数据",
"usageApiKeyRequired": "API 密钥必填",
"aggregationConflict": "此聚合提供商包含多条后端配置或多个 OpenAI 密钥。为避免数据丢失,请在源码配置中编辑。",
"partialMutationWarning": "操作失败,但一个或多个协议可能已更新。已重新读取最新配置,请检查后再重试。",
"groupedKeysTitle": "分组 Key",
"groupedKeysHint": "平台创建 Key 时可能需要选择分组,这里每个分组 Key 只管理一个协议,模型也只从该协议拉取。",
"groupedKey": "分组 Key #{{index}}",
+1
View File
@@ -1385,6 +1385,7 @@
"usageEmpty": "端點未回傳額度資料",
"usageApiKeyRequired": "API 金鑰必填",
"aggregationConflict": "此聚合提供商包含多筆後端設定或多個 OpenAI 金鑰。為避免資料遺失,請在原始碼設定中編輯。",
"partialMutationWarning": "操作失敗,但一個或多個協議可能已更新。已重新讀取最新設定,請檢查後再重試。",
"groupedKeysTitle": "分組金鑰",
"groupedKeysHint": "平台建立 Key 時可能需要選擇分組,這裡每個分組金鑰只管理一個協議,模型也只從該協議拉取。",
"groupedKey": "分組金鑰 #{{index}}",
+40
View File
@@ -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 });
});
});