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}
+ />
+
+ ))}
+
+
{t('ai_providers.ampcode_upstream_api_keys_hint')}
+
+
{
- setMappingsDirty(true);
+ setModelMappingsDirty(true);
setForm((prev) => ({ ...prev, mappingEntries: entries }));
}}
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
diff --git a/src/pages/AiProvidersEditLayout.module.scss b/src/pages/AiProvidersEditLayout.module.scss
index 84d97dc..00687c7 100644
--- a/src/pages/AiProvidersEditLayout.module.scss
+++ b/src/pages/AiProvidersEditLayout.module.scss
@@ -31,3 +31,45 @@
color: var(--text-secondary);
font-size: 13px;
}
+
+.ampcodeUpstreamMappingsHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+
+ label {
+ margin: 0;
+ }
+}
+
+.ampcodeUpstreamMappingsList {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.ampcodeUpstreamMappingCard {
+ border: 1px solid var(--border-color);
+ border-radius: 12px;
+ background: var(--bg-secondary);
+ padding: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.ampcodeUpstreamMappingCardTop {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.ampcodeUpstreamMappingTitle {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--text-secondary);
+}
diff --git a/src/pages/AiProvidersVertexEditPage.tsx b/src/pages/AiProvidersVertexEditPage.tsx
index 90cb9ea..90596e0 100644
--- a/src/pages/AiProvidersVertexEditPage.tsx
+++ b/src/pages/AiProvidersVertexEditPage.tsx
@@ -13,6 +13,7 @@ import { SecondaryScreenShell } from '@/components/common/SecondaryScreenShell';
import { providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import type { ProviderKeyConfig } from '@/types';
+import { excludedModelsToText, parseExcludedModels } from '@/components/providers/utils';
import { buildHeaderObject, headersToEntries, normalizeHeaderEntries } from '@/utils/headers';
import type { VertexFormState } from '@/components/providers';
import layoutStyles from './AiProvidersEditLayout.module.scss';
@@ -26,7 +27,9 @@ const buildEmptyForm = (): VertexFormState => ({
proxyUrl: '',
headers: [],
models: [],
+ excludedModels: [],
modelEntries: [{ name: '', alias: '' }],
+ excludedText: '',
});
const parseIndexParam = (value: string | undefined) => {
@@ -54,6 +57,7 @@ const buildVertexSignature = (form: VertexFormState) =>
proxyUrl: String(form.proxyUrl ?? '').trim(),
headers: normalizeHeaderEntries(form.headers),
models: normalizeModelEntries(form.modelEntries),
+ excludedModels: parseExcludedModels(form.excludedText ?? ''),
});
export function AiProvidersVertexEditPage() {
@@ -153,6 +157,7 @@ export function AiProvidersVertexEditPage() {
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
+ excludedText: excludedModelsToText(initialData.excludedModels),
};
setForm(nextForm);
setBaselineSignature(buildVertexSignature(nextForm));
@@ -213,6 +218,7 @@ export function AiProvidersVertexEditPage() {
return { name, alias };
})
.filter(Boolean) as ProviderKeyConfig['models'],
+ excludedModels: parseExcludedModels(form.excludedText),
};
const nextList =
@@ -343,6 +349,18 @@ export function AiProvidersVertexEditPage() {
/>
{t('ai_providers.vertex_models_hint')}
+
+
+
>
)}
diff --git a/src/services/api/ampcode.ts b/src/services/api/ampcode.ts
index 392df6b..4071f08 100644
--- a/src/services/api/ampcode.ts
+++ b/src/services/api/ampcode.ts
@@ -3,8 +3,18 @@
*/
import { apiClient } from './client';
-import { normalizeAmpcodeConfig, normalizeAmpcodeModelMappings } from './transformers';
-import type { AmpcodeConfig, AmpcodeModelMapping } from '@/types';
+import {
+ normalizeAmpcodeConfig,
+ normalizeAmpcodeModelMappings,
+ normalizeAmpcodeUpstreamApiKeys,
+} from './transformers';
+import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping } from '@/types';
+
+const serializeUpstreamApiKeyMappings = (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
+ mappings.map((mapping) => ({
+ 'upstream-api-key': mapping.upstreamApiKey,
+ 'api-keys': mapping.apiKeys,
+ }));
export const ampcodeApi = {
async getAmpcode(): Promise {
@@ -18,6 +28,19 @@ export const ampcodeApi = {
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
+ async getUpstreamApiKeys(): Promise {
+ const data = await apiClient.get>('/ampcode/upstream-api-keys');
+ const list = data?.['upstream-api-keys'] ?? data?.upstreamApiKeys ?? data?.items ?? data;
+ return normalizeAmpcodeUpstreamApiKeys(list);
+ },
+
+ saveUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
+ apiClient.put('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
+ patchUpstreamApiKeys: (mappings: AmpcodeUpstreamApiKeyMapping[]) =>
+ apiClient.patch('/ampcode/upstream-api-keys', { value: serializeUpstreamApiKeyMappings(mappings) }),
+ deleteUpstreamApiKeys: (upstreamApiKeys: string[]) =>
+ apiClient.delete('/ampcode/upstream-api-keys', { data: { value: upstreamApiKeys } }),
+
async getModelMappings(): Promise {
const data = await apiClient.get>('/ampcode/model-mappings');
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
@@ -34,4 +57,3 @@ export const ampcodeApi = {
updateForceModelMappings: (enabled: boolean) => apiClient.put('/ampcode/force-model-mappings', { value: enabled })
};
-
diff --git a/src/services/api/providers.ts b/src/services/api/providers.ts
index c522905..c294731 100644
--- a/src/services/api/providers.ts
+++ b/src/services/api/providers.ts
@@ -107,6 +107,9 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
if (headers) payload.headers = headers;
const models = serializeVertexModelAliases(config.models);
if (models && models.length) payload.models = models;
+ if (config.excludedModels && config.excludedModels.length) {
+ payload['excluded-models'] = config.excludedModels;
+ }
return payload;
};
diff --git a/src/services/api/transformers.ts b/src/services/api/transformers.ts
index 3de5d3a..08b21fe 100644
--- a/src/services/api/transformers.ts
+++ b/src/services/api/transformers.ts
@@ -6,7 +6,8 @@ import type {
OpenAIProviderConfig,
ProviderKeyConfig,
AmpcodeConfig,
- AmpcodeModelMapping
+ AmpcodeModelMapping,
+ AmpcodeUpstreamApiKeyMapping
} from '@/types';
import type { Config } from '@/types/config';
import { buildHeaderObject } from '@/utils/headers';
@@ -276,6 +277,33 @@ const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] =>
return mappings;
};
+const normalizeAmpcodeUpstreamApiKeys = (input: unknown): AmpcodeUpstreamApiKeyMapping[] => {
+ if (!Array.isArray(input)) return [];
+
+ const seen = new Set();
+ const mappings: AmpcodeUpstreamApiKeyMapping[] = [];
+
+ input.forEach((entry) => {
+ if (!isRecord(entry)) return;
+
+ const upstreamApiKey = String(
+ entry['upstream-api-key'] ?? entry.upstreamApiKey ?? entry['upstream_api_key'] ?? ''
+ ).trim();
+ if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
+
+ const rawApiKeys = entry['api-keys'] ?? entry.apiKeys ?? entry['api_keys'] ?? [];
+ const apiKeys = Array.isArray(rawApiKeys)
+ ? Array.from(new Set(rawApiKeys.map((item) => String(item ?? '').trim()).filter(Boolean)))
+ : [];
+ if (!apiKeys.length) return;
+
+ seen.add(upstreamApiKey);
+ mappings.push({ upstreamApiKey, apiKeys });
+ });
+
+ return mappings;
+};
+
const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined => {
const sourceRaw = isRecord(payload) ? (payload.ampcode ?? payload) : payload;
if (!isRecord(sourceRaw)) return undefined;
@@ -287,6 +315,13 @@ const normalizeAmpcodeConfig = (payload: unknown): AmpcodeConfig | undefined =>
const upstreamApiKey = source['upstream-api-key'] ?? source.upstreamApiKey ?? source['upstream_api_key'];
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
+ const upstreamApiKeys = normalizeAmpcodeUpstreamApiKeys(
+ source['upstream-api-keys'] ?? source.upstreamApiKeys ?? source['upstream_api_keys']
+ );
+ if (upstreamApiKeys.length) {
+ config.upstreamApiKeys = upstreamApiKeys;
+ }
+
const forceModelMappings = normalizeBoolean(
source['force-model-mappings'] ?? source.forceModelMappings ?? source['force_model_mappings']
);
@@ -420,5 +455,6 @@ export {
normalizeHeaders,
normalizeExcludedModels,
normalizeAmpcodeConfig,
- normalizeAmpcodeModelMappings
+ normalizeAmpcodeModelMappings,
+ normalizeAmpcodeUpstreamApiKeys
};
diff --git a/src/types/ampcode.ts b/src/types/ampcode.ts
index 7668dee..e2af8cc 100644
--- a/src/types/ampcode.ts
+++ b/src/types/ampcode.ts
@@ -7,10 +7,15 @@ export interface AmpcodeModelMapping {
to: string;
}
+export interface AmpcodeUpstreamApiKeyMapping {
+ upstreamApiKey: string;
+ apiKeys: string[];
+}
+
export interface AmpcodeConfig {
upstreamUrl?: string;
upstreamApiKey?: string;
+ upstreamApiKeys?: AmpcodeUpstreamApiKeyMapping[];
modelMappings?: AmpcodeModelMapping[];
forceModelMappings?: boolean;
}
-