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