mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-08-28 16:31:13 +08:00
fix(providers): recover from partial sponsor updates
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -1337,6 +1337,7 @@
|
||||
"usageEmpty": "Endpoint не вернул данные квоты.",
|
||||
"usageApiKeyRequired": "Требуется API key",
|
||||
"aggregationConflict": "Этот объединённый провайдер содержит несколько серверных конфигураций или ключей OpenAI. Чтобы избежать потери данных, измените исходную конфигурацию.",
|
||||
"partialMutationWarning": "Операция завершилась ошибкой, но один или несколько протоколов могли обновиться. Последняя конфигурация загружена повторно; проверьте её перед повторной попыткой.",
|
||||
"groupedKeysTitle": "Групповые ключи",
|
||||
"groupedKeysHint": "Ключи платформы могут привязываться к группе при создании. Каждый групповой ключ здесь управляет одним протоколом, а модели загружаются только из этого протокола.",
|
||||
"groupedKey": "Групповой ключ #{{index}}",
|
||||
|
||||
@@ -1359,6 +1359,7 @@
|
||||
"usageEmpty": "端点未返回额度数据",
|
||||
"usageApiKeyRequired": "API 密钥必填",
|
||||
"aggregationConflict": "此聚合提供商包含多条后端配置或多个 OpenAI 密钥。为避免数据丢失,请在源码配置中编辑。",
|
||||
"partialMutationWarning": "操作失败,但一个或多个协议可能已更新。已重新读取最新配置,请检查后再重试。",
|
||||
"groupedKeysTitle": "分组 Key",
|
||||
"groupedKeysHint": "平台创建 Key 时可能需要选择分组,这里每个分组 Key 只管理一个协议,模型也只从该协议拉取。",
|
||||
"groupedKey": "分组 Key #{{index}}",
|
||||
|
||||
@@ -1385,6 +1385,7 @@
|
||||
"usageEmpty": "端點未回傳額度資料",
|
||||
"usageApiKeyRequired": "API 金鑰必填",
|
||||
"aggregationConflict": "此聚合提供商包含多筆後端設定或多個 OpenAI 金鑰。為避免資料遺失,請在原始碼設定中編輯。",
|
||||
"partialMutationWarning": "操作失敗,但一個或多個協議可能已更新。已重新讀取最新設定,請檢查後再重試。",
|
||||
"groupedKeysTitle": "分組金鑰",
|
||||
"groupedKeysHint": "平台建立 Key 時可能需要選擇分組,這裡每個分組金鑰只管理一個協議,模型也只從該協議拉取。",
|
||||
"groupedKey": "分組金鑰 #{{index}}",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user