fix(providers): guard lossy sponsor aggregation

This commit is contained in:
Supra4E8C
2026-07-10 02:55:30 +08:00
parent 022634b6fd
commit ab6b0b3c48
7 changed files with 109 additions and 14 deletions
@@ -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<SponsorKeyEntryInput>) => {
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 (
<form id={formId} className={formClassName} onSubmit={(event) => event.preventDefault()}>
<div className={styles.errorBox}>{t('providersPage.sponsor.aggregationConflict')}</div>
</form>
);
}
return (
<form id={formId} className={formClassName} onSubmit={handleSubmit} noValidate>
+31 -8
View File
@@ -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<SponsorProviderBrand, SponsorProviderDefinitio
},
};
export const isMultiProtocolSponsorBrand = (
brand: ProviderBrand
): brand is SponsorProviderBrand =>
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
+1
View File
@@ -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}}",
+1
View File
@@ -1335,6 +1335,7 @@
"usageInvalid": "Недействительный ключ",
"usageEmpty": "Endpoint не вернул данные квоты.",
"usageApiKeyRequired": "Требуется API key",
"aggregationConflict": "Этот объединённый провайдер содержит несколько серверных конфигураций или ключей OpenAI. Чтобы избежать потери данных, измените исходную конфигурацию.",
"groupedKeysTitle": "Групповые ключи",
"groupedKeysHint": "Ключи платформы могут привязываться к группе при создании. Каждый групповой ключ здесь управляет одним протоколом, а модели загружаются только из этого протокола.",
"groupedKey": "Групповой ключ #{{index}}",
+1
View File
@@ -1357,6 +1357,7 @@
"usageInvalid": "Key 无效",
"usageEmpty": "端点未返回额度数据",
"usageApiKeyRequired": "API 密钥必填",
"aggregationConflict": "此聚合提供商包含多条后端配置或多个 OpenAI 密钥。为避免数据丢失,请在源码配置中编辑。",
"groupedKeysTitle": "分组 Key",
"groupedKeysHint": "平台创建 Key 时可能需要选择分组,这里每个分组 Key 只管理一个协议,模型也只从该协议拉取。",
"groupedKey": "分组 Key #{{index}}",
+1
View File
@@ -1383,6 +1383,7 @@
"usageInvalid": "Key 無效",
"usageEmpty": "端點未回傳額度資料",
"usageApiKeyRequired": "API 金鑰必填",
"aggregationConflict": "此聚合提供商包含多筆後端設定或多個 OpenAI 金鑰。為避免資料遺失,請在原始碼設定中編輯。",
"groupedKeysTitle": "分組金鑰",
"groupedKeysHint": "平台建立 Key 時可能需要選擇分組,這裡每個分組金鑰只管理一個協議,模型也只從該協議拉取。",
"groupedKey": "分組金鑰 #{{index}}",
+55
View File
@@ -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();
});
});