From 38d7e20320426edef1821bffecc2001b76d52ea9 Mon Sep 17 00:00:00 2001 From: Supra4E8C Date: Sat, 7 Mar 2026 15:36:47 +0800 Subject: [PATCH] feat(ai-providers): add ampcode multi-upstream routing and vertex excluded models --- .../AmpcodeSection/AmpcodeSection.tsx | 4 + .../providers/VertexSection/VertexSection.tsx | 15 ++ src/components/providers/types.ts | 9 +- src/components/providers/utils.ts | 38 ++++- src/i18n/locales/en.json | 9 ++ src/i18n/locales/ru.json | 9 ++ src/i18n/locales/zh-CN.json | 9 ++ src/pages/AiProvidersAmpcodeEditPage.tsx | 152 ++++++++++++++++-- src/pages/AiProvidersEditLayout.module.scss | 42 +++++ src/pages/AiProvidersVertexEditPage.tsx | 18 +++ src/services/api/ampcode.ts | 28 +++- src/services/api/providers.ts | 3 + src/services/api/transformers.ts | 40 ++++- src/types/ampcode.ts | 7 +- 14 files changed, 358 insertions(+), 25 deletions(-) diff --git a/src/components/providers/AmpcodeSection/AmpcodeSection.tsx b/src/components/providers/AmpcodeSection/AmpcodeSection.tsx index bd759c6..2748cc3 100644 --- a/src/components/providers/AmpcodeSection/AmpcodeSection.tsx +++ b/src/components/providers/AmpcodeSection/AmpcodeSection.tsx @@ -71,6 +71,10 @@ export function AmpcodeSection({ {t('ai_providers.ampcode_model_mappings_count')}: {config?.modelMappings?.length || 0} +
+ {t('ai_providers.ampcode_upstream_api_keys_count')}: + {config?.upstreamApiKeys?.length || 0} +
{config?.modelMappings?.length ? (
{config.modelMappings.slice(0, 5).map((mapping) => ( diff --git a/src/components/providers/VertexSection/VertexSection.tsx b/src/components/providers/VertexSection/VertexSection.tsx index 4303f78..fcf0541 100644 --- a/src/components/providers/VertexSection/VertexSection.tsx +++ b/src/components/providers/VertexSection/VertexSection.tsx @@ -87,6 +87,7 @@ export function VertexSection({ renderContent={(item, index) => { const stats = getStatsBySource(item.apiKey, keyStats, item.prefix); const headerEntries = Object.entries(item.headers || {}); + const excludedModels = item.excludedModels ?? []; const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]); return ( @@ -140,6 +141,20 @@ export function VertexSection({ ))}
) : null} + {excludedModels.length ? ( +
+
+ {t('ai_providers.excluded_models_count', { count: excludedModels.length })} +
+
+ {excludedModels.map((model) => ( + + {model} + + ))} +
+
+ ) : null}
{t('stats.success')}: {stats.success} diff --git a/src/components/providers/types.ts b/src/components/providers/types.ts index f80377a..7c8d8d6 100644 --- a/src/components/providers/types.ts +++ b/src/components/providers/types.ts @@ -18,11 +18,17 @@ export interface OpenAIFormState { apiKeyEntries: ApiKeyEntry[]; } +export interface AmpcodeUpstreamApiKeyEntry { + upstreamApiKey: string; + clientApiKeysText: string; +} + export interface AmpcodeFormState { upstreamUrl: string; upstreamApiKey: string; forceModelMappings: boolean; mappingEntries: ModelEntry[]; + upstreamApiKeyEntries: AmpcodeUpstreamApiKeyEntry[]; } export type GeminiFormState = Omit & { @@ -37,9 +43,10 @@ export type ProviderFormState = Omit & { excludedText: string; }; -export type VertexFormState = Omit & { +export type VertexFormState = Omit & { headers: HeaderEntry[]; modelEntries: ModelEntry[]; + excludedText: string; }; export interface ProviderSectionProps { diff --git a/src/components/providers/utils.ts b/src/components/providers/utils.ts index 3e4192c..fb07cb6 100644 --- a/src/components/providers/utils.ts +++ b/src/components/providers/utils.ts @@ -1,6 +1,6 @@ -import type { AmpcodeConfig, AmpcodeModelMapping, ApiKeyEntry } from '@/types'; +import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types'; import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage'; -import type { AmpcodeFormState, ModelEntry } from './types'; +import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types'; export const DISABLE_ALL_MODELS_RULE = '*'; @@ -168,9 +168,43 @@ export const entriesToAmpcodeMappings = (entries: ModelEntry[]): AmpcodeModelMap return mappings; }; +export const ampcodeUpstreamApiKeysToEntries = ( + mappings?: AmpcodeUpstreamApiKeyMapping[] +): AmpcodeUpstreamApiKeyEntry[] => { + if (!Array.isArray(mappings) || mappings.length === 0) { + return [{ upstreamApiKey: '', clientApiKeysText: '' }]; + } + + return mappings.map((mapping) => ({ + upstreamApiKey: mapping.upstreamApiKey ?? '', + clientApiKeysText: Array.isArray(mapping.apiKeys) ? mapping.apiKeys.join('\n') : '', + })); +}; + +export const entriesToAmpcodeUpstreamApiKeys = ( + entries: AmpcodeUpstreamApiKeyEntry[] +): AmpcodeUpstreamApiKeyMapping[] => { + const seen = new Set(); + const mappings: AmpcodeUpstreamApiKeyMapping[] = []; + + entries.forEach((entry) => { + const upstreamApiKey = String(entry?.upstreamApiKey ?? '').trim(); + if (!upstreamApiKey || seen.has(upstreamApiKey)) return; + + const apiKeys = Array.from(new Set(parseTextList(String(entry?.clientApiKeysText ?? '')))); + if (!apiKeys.length) return; + + seen.add(upstreamApiKey); + mappings.push({ upstreamApiKey, apiKeys }); + }); + + return mappings; +}; + export const buildAmpcodeFormState = (ampcode?: AmpcodeConfig | null): AmpcodeFormState => ({ upstreamUrl: ampcode?.upstreamUrl ?? '', upstreamApiKey: '', forceModelMappings: ampcode?.forceModelMappings ?? false, mappingEntries: ampcodeMappingsToEntries(ampcode?.modelMappings), + upstreamApiKeyEntries: ampcodeUpstreamApiKeysToEntries(ampcode?.upstreamApiKeys), }); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index e386699..86dac52 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -366,6 +366,13 @@ "ampcode_upstream_api_key_current": "Current Amp official key: {{key}}", "ampcode_clear_upstream_api_key": "Clear official key", "ampcode_clear_upstream_api_key_confirm": "Are you sure you want to clear the Ampcode upstream API key (Amp official)?", + "ampcode_upstream_api_keys_label": "Multi-upstream API key routing", + "ampcode_upstream_api_keys_hint": "Bind different Amp upstream API keys to specific client API keys. Client keys can be separated by commas or new lines.", + "ampcode_upstream_api_keys_add_btn": "Add upstream mapping", + "ampcode_upstream_api_keys_upstream_placeholder": "Upstream API key (sk-amp-...)", + "ampcode_upstream_api_keys_clients_placeholder": "Client API keys, separated by commas or new lines", + "ampcode_upstream_api_keys_item_title": "Upstream mapping #{{index}}", + "ampcode_upstream_api_keys_count": "Upstream mappings", "ampcode_force_model_mappings_label": "Force model mappings", "ampcode_force_model_mappings_hint": "When enabled, mappings override local API-key availability checks.", "ampcode_model_mappings_label": "Model mappings (from → to)", @@ -374,6 +381,8 @@ "ampcode_model_mappings_from_placeholder": "from model (source)", "ampcode_model_mappings_to_placeholder": "to model (target)", "ampcode_model_mappings_count": "Mappings Count", + "ampcode_lists_overwrite_title": "Overwrite list settings", + "ampcode_lists_overwrite_confirm": "Existing multi-upstream/model mapping lists could not be loaded. Continuing may overwrite or clear them. Continue?", "ampcode_mappings_overwrite_confirm": "Existing mappings could not be loaded. Continuing may overwrite or clear them. Continue?", "openai_title": "OpenAI Compatible Providers", "openai_add_button": "Add Provider", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index ad1e4a1..9c87d9c 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -366,6 +366,13 @@ "ampcode_upstream_api_key_current": "Текущий официальный ключ Amp: {{key}}", "ampcode_clear_upstream_api_key": "Очистить официальный ключ", "ampcode_clear_upstream_api_key_confirm": "Очистить upstream API-ключ Ampcode (официальный Amp)?", + "ampcode_upstream_api_keys_label": "Маршрутизация нескольких upstream API-ключей", + "ampcode_upstream_api_keys_hint": "Привяжите разные upstream API-ключи Amp к указанным клиентским API-ключам. Клиентские ключи можно разделять запятыми или переводами строки.", + "ampcode_upstream_api_keys_add_btn": "Добавить upstream-сопоставление", + "ampcode_upstream_api_keys_upstream_placeholder": "Upstream API-ключ (sk-amp-...)", + "ampcode_upstream_api_keys_clients_placeholder": "Клиентские API-ключи, через запятую или с новой строки", + "ampcode_upstream_api_keys_item_title": "Upstream-сопоставление #{{index}}", + "ampcode_upstream_api_keys_count": "Количество upstream-сопоставлений", "ampcode_force_model_mappings_label": "Принудительно применять сопоставления моделей", "ampcode_force_model_mappings_hint": "При включении сопоставления переопределяют локальные проверки доступности API-ключей.", "ampcode_model_mappings_label": "Сопоставления моделей (из → в)", @@ -374,6 +381,8 @@ "ampcode_model_mappings_from_placeholder": "исходная модель", "ampcode_model_mappings_to_placeholder": "целевая модель", "ampcode_model_mappings_count": "Количество сопоставлений", + "ampcode_lists_overwrite_title": "Перезаписать списки", + "ampcode_lists_overwrite_confirm": "Существующие списки multi-upstream/сопоставлений моделей не удалось загрузить. Продолжение может перезаписать или очистить их. Продолжить?", "ampcode_mappings_overwrite_confirm": "Не удалось загрузить существующие сопоставления. Продолжение может перезаписать или очистить их. Продолжить?", "openai_title": "Совместимые с OpenAI провайдеры", "openai_add_button": "Добавить провайдера", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 860ef81..f3478f2 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -366,6 +366,13 @@ "ampcode_upstream_api_key_current": "当前Amp官方密钥: {{key}}", "ampcode_clear_upstream_api_key": "清除官方密钥", "ampcode_clear_upstream_api_key_confirm": "确定要清除 Ampcode 的 upstream API key(Amp官方)吗?", + "ampcode_upstream_api_keys_label": "多上游 API Key 路由", + "ampcode_upstream_api_keys_hint": "为指定客户端 API Key 绑定不同的 Amp 上游 API Key;客户端 key 可用逗号或换行分隔。", + "ampcode_upstream_api_keys_add_btn": "添加多上游映射", + "ampcode_upstream_api_keys_upstream_placeholder": "上游 API Key(sk-amp-...)", + "ampcode_upstream_api_keys_clients_placeholder": "客户端 API Keys,用逗号或换行分隔", + "ampcode_upstream_api_keys_item_title": "上游映射 #{{index}}", + "ampcode_upstream_api_keys_count": "多上游映射", "ampcode_force_model_mappings_label": "强制应用模型映射", "ampcode_force_model_mappings_hint": "开启后,模型映射将覆盖本地 API Key 可用性判断。", "ampcode_model_mappings_label": "模型映射 (from → to)", @@ -374,6 +381,8 @@ "ampcode_model_mappings_from_placeholder": "from 模型(原始)", "ampcode_model_mappings_to_placeholder": "to 模型(目标)", "ampcode_model_mappings_count": "映射数量", + "ampcode_lists_overwrite_title": "覆盖列表配置", + "ampcode_lists_overwrite_confirm": "当前未成功加载服务器已有多上游/模型映射配置,继续保存可能覆盖或清空这些列表,是否继续?", "ampcode_mappings_overwrite_confirm": "当前未成功加载服务器已有映射,继续保存可能覆盖或清空已有映射,是否继续?", "openai_title": "OpenAI 兼容提供商", "openai_add_button": "添加提供商", diff --git a/src/pages/AiProvidersAmpcodeEditPage.tsx b/src/pages/AiProvidersAmpcodeEditPage.tsx index a90e54e..ebc9f7c 100644 --- a/src/pages/AiProvidersAmpcodeEditPage.tsx +++ b/src/pages/AiProvidersAmpcodeEditPage.tsx @@ -13,7 +13,11 @@ import { ampcodeApi } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import type { AmpcodeConfig } from '@/types'; import { maskApiKey } from '@/utils/format'; -import { buildAmpcodeFormState, entriesToAmpcodeMappings } from '@/components/providers/utils'; +import { + buildAmpcodeFormState, + entriesToAmpcodeMappings, + entriesToAmpcodeUpstreamApiKeys, +} from '@/components/providers/utils'; import type { AmpcodeFormState } from '@/components/providers'; import layoutStyles from './AiProvidersEditLayout.module.scss'; @@ -34,11 +38,18 @@ const normalizeMappingEntries = (entries: Array<{ name: string; alias: string }> return acc; }, []); +const normalizeUpstreamApiKeyEntries = (form: AmpcodeFormState) => + entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries).map((entry) => ({ + upstreamApiKey: entry.upstreamApiKey, + apiKeys: entry.apiKeys, + })); + const buildAmpcodeSignature = (form: AmpcodeFormState) => JSON.stringify({ upstreamUrl: String(form.upstreamUrl ?? '').trim(), upstreamApiKey: String(form.upstreamApiKey ?? '').trim(), forceModelMappings: Boolean(form.forceModelMappings), + upstreamApiKeys: normalizeUpstreamApiKeyEntries(form), modelMappings: normalizeMappingEntries(form.mappingEntries), }); @@ -57,7 +68,8 @@ export function AiProvidersAmpcodeEditPage() { const [form, setForm] = useState(() => buildAmpcodeFormState(null)); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); - const [mappingsDirty, setMappingsDirty] = useState(false); + const [modelMappingsDirty, setModelMappingsDirty] = useState(false); + const [upstreamApiKeysDirty, setUpstreamApiKeysDirty] = useState(false); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); const [baselineSignature, setBaselineSignature] = useState(() => @@ -102,7 +114,8 @@ export function AiProvidersAmpcodeEditPage() { setLoading(true); setLoaded(false); - setMappingsDirty(false); + setModelMappingsDirty(false); + setUpstreamApiKeysDirty(false); setError(''); const initialForm = buildAmpcodeFormState(useConfigStore.getState().config?.ampcode ?? null); setForm(initialForm); @@ -183,6 +196,7 @@ export function AiProvidersAmpcodeEditPage() { try { const upstreamUrl = form.upstreamUrl.trim(); const overrideKey = form.upstreamApiKey.trim(); + const upstreamApiKeys = entriesToAmpcodeUpstreamApiKeys(form.upstreamApiKeyEntries); const modelMappings = entriesToAmpcodeMappings(form.mappingEntries); if (upstreamUrl) { @@ -193,7 +207,15 @@ export function AiProvidersAmpcodeEditPage() { await ampcodeApi.updateForceModelMappings(form.forceModelMappings); - if (loaded || mappingsDirty) { + if (loaded || upstreamApiKeysDirty) { + if (upstreamApiKeys.length) { + await ampcodeApi.saveUpstreamApiKeys(upstreamApiKeys); + } else { + await ampcodeApi.deleteUpstreamApiKeys([]); + } + } + + if (loaded || modelMappingsDirty) { if (modelMappings.length) { await ampcodeApi.saveModelMappings(modelMappings); } else { @@ -207,23 +229,29 @@ export function AiProvidersAmpcodeEditPage() { const previous = config?.ampcode ?? {}; const next: AmpcodeConfig = { - upstreamUrl: upstreamUrl || undefined, + ...previous, forceModelMappings: form.forceModelMappings, }; - if (previous.upstreamApiKey) { - next.upstreamApiKey = previous.upstreamApiKey; - } - - if (Array.isArray(previous.modelMappings)) { - next.modelMappings = previous.modelMappings; + if (upstreamUrl) { + next.upstreamUrl = upstreamUrl; + } else { + delete next.upstreamUrl; } if (overrideKey) { next.upstreamApiKey = overrideKey; } - if (loaded || mappingsDirty) { + if (loaded || upstreamApiKeysDirty) { + if (upstreamApiKeys.length) { + next.upstreamApiKeys = upstreamApiKeys; + } else { + delete next.upstreamApiKeys; + } + } + + if (loaded || modelMappingsDirty) { if (modelMappings.length) { next.modelMappings = modelMappings; } else { @@ -247,10 +275,10 @@ export function AiProvidersAmpcodeEditPage() { }; const saveAmpcode = async () => { - if (!loaded && mappingsDirty) { + if (!loaded && (modelMappingsDirty || upstreamApiKeysDirty)) { showConfirmation({ - title: t('ai_providers.ampcode_mappings_overwrite_title', { defaultValue: 'Overwrite Mappings' }), - message: t('ai_providers.ampcode_mappings_overwrite_confirm'), + title: t('ai_providers.ampcode_lists_overwrite_title'), + message: t('ai_providers.ampcode_lists_overwrite_confirm'), variant: 'secondary', confirmText: t('common.confirm'), onConfirm: performSaveAmpcode, @@ -334,6 +362,98 @@ export function AiProvidersAmpcodeEditPage() {
+
+
+ + +
+
+ {(form.upstreamApiKeyEntries.length + ? form.upstreamApiKeyEntries + : [{ upstreamApiKey: '', clientApiKeysText: '' }] + ).map((entry, index, entries) => ( +
+
+ + {t('ai_providers.ampcode_upstream_api_keys_item_title', { index: index + 1 })} + + +
+ { + const value = e.target.value; + setUpstreamApiKeysDirty(true); + setForm((prev) => ({ + ...prev, + upstreamApiKeyEntries: prev.upstreamApiKeyEntries.map((item, itemIndex) => + itemIndex === index ? { ...item, upstreamApiKey: value } : item + ), + })); + }} + disabled={loading || saving || disableControls} + /> +