diff --git a/src/features/providers/sheets/forms/SponsorProviderForm.tsx b/src/features/providers/sheets/forms/SponsorProviderForm.tsx index 02580d73..bf08187a 100644 --- a/src/features/providers/sheets/forms/SponsorProviderForm.tsx +++ b/src/features/providers/sheets/forms/SponsorProviderForm.tsx @@ -20,6 +20,7 @@ import type { ModelInfo } from '@/utils/models'; import type { ApiKeyFunUsageSummary } from '../../sponsor'; import { discoveryBrandForSponsorProtocol, + getSponsorAggregationConflict, getSponsorProviderDefinition, sponsorProtocolI18nKey, sponsorProtocolModelI18nKey, @@ -410,12 +411,12 @@ function SponsorKeyEntryCard({ fallbackApiKey: entry.existingApiKey, apiKeyEntries: entry.protocol === 'openai' ? openaiDiscoveryEntries : undefined, }); - const protocolOptions = definition.protocols.filter( - (protocol) => protocol === entry.protocol || !usedProtocols.has(protocol) - ).map((protocol) => ({ - value: protocol, - label: t(`providersPage.sponsor.protocols.${sponsorProtocolI18nKey(protocol)}`), - })); + const protocolOptions = definition.protocols + .filter((protocol) => protocol === entry.protocol || !usedProtocols.has(protocol)) + .map((protocol) => ({ + value: protocol, + label: t(`providersPage.sponsor.protocols.${sponsorProtocolI18nKey(protocol)}`), + })); const updateEntry = (patch: Partial) => { onChange({ ...entry, ...patch }); @@ -843,6 +844,18 @@ export function SponsorProviderForm({ const formClassName = [styles.form, variant === 'quickStart' ? styles.quickStartForm : ''] .filter(Boolean) .join(' '); + const aggregationConflict = + mode === 'edit' + ? getSponsorAggregationConflict(getSponsorRaw(resource, definition.brand)) + : null; + + if (aggregationConflict) { + return ( +
event.preventDefault()}> +
{t('providersPage.sponsor.aggregationConflict')}
+
+ ); + } return (
diff --git a/src/features/providers/sponsorDefinitions.ts b/src/features/providers/sponsorDefinitions.ts index a4d652f1..ba4451fa 100644 --- a/src/features/providers/sponsorDefinitions.ts +++ b/src/features/providers/sponsorDefinitions.ts @@ -35,7 +35,12 @@ import { getQiniuCloudProtocolUrls, resolveQiniuCloudBaseUrl, } from './qiniuCloud'; -import type { ProviderBrand, SponsorProtocol, SponsorProviderBrand } from './types'; +import type { + ProviderBrand, + SponsorProtocol, + SponsorProviderBrand, + SponsorProviderRaw, +} from './types'; export interface SponsorProtocolUrls { anthropic: string; @@ -125,13 +130,31 @@ const SPONSOR_DEFINITIONS: Record - brand === 'apikeyFun' || - brand === 'code0' || - brand === 'fennoAI' || - brand === 'qiniuCloud'; +export const isMultiProtocolSponsorBrand = (brand: ProviderBrand): brand is SponsorProviderBrand => + brand === 'apikeyFun' || brand === 'code0' || brand === 'fennoAI' || brand === 'qiniuCloud'; + +export type SponsorAggregationConflict = 'multiple-configs' | 'multiple-openai-keys'; + +export const getSponsorAggregationConflict = ( + raw: SponsorProviderRaw | null | undefined +): SponsorAggregationConflict | null => { + if (!raw) return null; + if ( + raw.openai.length > 1 || + raw.claude.length > 1 || + raw.codex.length > 1 || + raw.gemini.length > 1 + ) { + return 'multiple-configs'; + } + + const openAIKeyCount = raw.openai.reduce( + (count, item) => + count + (item.config.apiKeyEntries ?? []).filter((entry) => entry.apiKey?.trim()).length, + 0 + ); + return openAIKeyCount > 1 ? 'multiple-openai-keys' : null; +}; export const getSponsorProviderDefinition = ( brand: SponsorProviderBrand diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 78e980fa..ff4a8293 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1357,6 +1357,7 @@ "usageInvalid": "Invalid key", "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.", "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 a3e937ef..350771e4 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1335,6 +1335,7 @@ "usageInvalid": "Недействительный ключ", "usageEmpty": "Endpoint не вернул данные квоты.", "usageApiKeyRequired": "Требуется API key", + "aggregationConflict": "Этот объединённый провайдер содержит несколько серверных конфигураций или ключей OpenAI. Чтобы избежать потери данных, измените исходную конфигурацию.", "groupedKeysTitle": "Групповые ключи", "groupedKeysHint": "Ключи платформы могут привязываться к группе при создании. Каждый групповой ключ здесь управляет одним протоколом, а модели загружаются только из этого протокола.", "groupedKey": "Групповой ключ #{{index}}", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 14da7145..3a0e5fcd 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1357,6 +1357,7 @@ "usageInvalid": "Key 无效", "usageEmpty": "端点未返回额度数据", "usageApiKeyRequired": "API 密钥必填", + "aggregationConflict": "此聚合提供商包含多条后端配置或多个 OpenAI 密钥。为避免数据丢失,请在源码配置中编辑。", "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 16adaeca..4751668d 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -1383,6 +1383,7 @@ "usageInvalid": "Key 無效", "usageEmpty": "端點未回傳額度資料", "usageApiKeyRequired": "API 金鑰必填", + "aggregationConflict": "此聚合提供商包含多筆後端設定或多個 OpenAI 金鑰。為避免資料遺失,請在原始碼設定中編輯。", "groupedKeysTitle": "分組金鑰", "groupedKeysHint": "平台建立 Key 時可能需要選擇分組,這裡每個分組金鑰只管理一個協議,模型也只從該協議拉取。", "groupedKey": "分組金鑰 #{{index}}", diff --git a/tests/sponsorAggregation.test.ts b/tests/sponsorAggregation.test.ts new file mode 100644 index 00000000..9c6a9a2e --- /dev/null +++ b/tests/sponsorAggregation.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from 'bun:test'; +import { getSponsorAggregationConflict } from '../src/features/providers/sponsorDefinitions'; +import type { SponsorProviderRaw } from '../src/features/providers/types'; + +const emptyRaw = (): SponsorProviderRaw => ({ + openai: [], + claude: [], + codex: [], + gemini: [], +}); + +describe('sponsor aggregation safety', () => { + test('detects multiple configs for one protocol', () => { + const raw = emptyRaw(); + raw.codex = [ + { index: 0, config: { apiKey: 'first' } }, + { index: 1, config: { apiKey: 'second' } }, + ]; + + expect(getSponsorAggregationConflict(raw)).toBe('multiple-configs'); + }); + + test('detects multiple OpenAI API keys in one config', () => { + const raw = emptyRaw(); + raw.openai = [ + { + index: 0, + config: { + name: 'Sponsor', + baseUrl: 'https://example.com/v1', + apiKeyEntries: [{ apiKey: 'first' }, { apiKey: 'second' }], + }, + }, + ]; + + expect(getSponsorAggregationConflict(raw)).toBe('multiple-openai-keys'); + }); + + test('allows the supported one-config-per-protocol shape', () => { + const raw = emptyRaw(); + raw.codex = [{ index: 0, config: { apiKey: 'codex' } }]; + raw.openai = [ + { + index: 0, + config: { + name: 'Sponsor', + baseUrl: 'https://example.com/v1', + apiKeyEntries: [{ apiKey: 'openai' }], + }, + }, + ]; + + expect(getSponsorAggregationConflict(raw)).toBeNull(); + }); +});