Refactor: Remove Ampcode integration and related configurations

- Updated BaseProviderForm to accept all ProviderBrand types.
- Removed Ampcode references from ProviderResource and related types.
- Deleted ampcode API service and its associated methods.
- Cleaned up transformers by removing Ampcode normalization functions.
- Updated configuration store to eliminate Ampcode section.
- Adjusted DashboardPage to remove Ampcode-related statistics.
- Removed Ampcode localization strings from all language files.
This commit is contained in:
LTbinglingfeng
2026-06-15 00:51:57 +08:00
Unverified
parent b9b45e9034
commit 1969de4e28
27 changed files with 75 additions and 929 deletions
-1
View File
@@ -78,7 +78,6 @@ Check the CLI Proxy API server documentation/config comments for the full authen
- **AI Providers**:
- Gemini/Codex/Claude/Vertex key entries (base URL, headers, proxy, model aliases, excluded models, prefix).
- OpenAI-compatible providers (multiple API keys, custom headers, model alias import via `/v1/models`, optional browser-side "chat/completions" test).
- Ampcode integration (upstream URL/key, force mappings, model mapping table).
- **Auth Files**: upload/download/delete JSON credentials, filter/search/pagination, runtime-only indicators, view supported models per credential (when the server supports it), manage OAuth excluded models (supports `*` wildcards), configure OAuth model alias mappings.
- **OAuth**: start OAuth/device flows for Codex, Anthropic/Claude, Antigravity, Gemini CLI, Kimi, and xAI/Grok; poll status; submit callback URLs or xAI/Grok displayed codes; import Vertex JSON credentials and iFlow cookies.
- **Quota Management**: manage quota limits and usage for Claude, Antigravity, Codex, Gemini CLI, and other providers.
-1
View File
@@ -78,7 +78,6 @@ bun run build
- **AI 提供商**
- Gemini/Codex/Claude/Vertex 配置(Base URL、Headers、代理、模型别名、排除模型、Prefix)。
- OpenAI 兼容提供商(多 Key、Header、自助从 `/v1/models` 拉取并导入模型别名、可选浏览器侧 `chat/completions` 测试)。
- Ampcode 集成(上游地址/密钥、强制映射、模型映射表)。
- **认证文件**:上传/下载/删除 JSON 凭据,筛选/搜索/分页,标记 runtime-only;查看单个凭据可用模型(依赖后端支持);管理 OAuth 排除模型(支持 `*` 通配符);配置 OAuth 模型别名映射。
- **OAuth**:对 Codex、Anthropic/Claude、Antigravity、Gemini CLI、Kimi、xAI/Grok 发起 OAuth/设备码流程并轮询状态;支持提交回调 URL 或 xAI/Grok 页面显示的 code;包含 Vertex JSON 凭据导入与 iFlow Cookie 导入。
- **配额管理**:管理 Claude、Antigravity、Codex、Gemini CLI 等提供商的配额上限与使用情况。
-6
View File
@@ -1,6 +0,0 @@
<svg width="400" height="400" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.9197 13.61L17.3816 26.566L14.242 27.4049L11.2645 16.2643L0.119926 13.2906L0.957817 10.15L13.9197 13.61Z" fill="#F34E3F"/>
<path d="M13.7391 16.0892L4.88169 24.9056L2.58872 22.6019L11.4461 13.7865L13.7391 16.0892Z" fill="#F34E3F"/>
<path d="M18.9386 8.58315L22.4005 21.5392L19.2609 22.3781L16.2833 11.2374L5.13879 8.26381L5.97668 5.12318L18.9386 8.58315Z" fill="#F34E3F"/>
<path d="M23.9803 3.55632L27.4422 16.5124L24.3025 17.3512L21.325 6.21062L10.1805 3.23698L11.0183 0.0963593L23.9803 3.55632Z" fill="#F34E3F"/>
</svg>

Before

Width:  |  Height:  |  Size: 632 B

@@ -79,7 +79,6 @@ const getResourceRecentSuccess = (
usageByProvider
).success;
}
if (resource.brand === 'ampcode') return 0;
return getProviderRecentWindowStats(
usageByProvider,
resource.brand,
@@ -285,15 +284,8 @@ export function ProvidersWorkbenchPage() {
const openCreate = useCallback(() => {
const brand = activeBrand;
if (brand === 'ampcode') {
// ampcode 走单例编辑
const r =
groups.find((g) => g.id === 'ampcode')?.resources[0] ?? null;
setSheetState({ open: true, brand: 'ampcode', mode: 'edit', resource: r });
} else {
setSheetState({ open: true, brand, mode: 'create', resource: null });
}
}, [activeBrand, groups]);
setSheetState({ open: true, brand, mode: 'create', resource: null });
}, [activeBrand]);
const openView = useCallback((resource: ProviderResource) => {
setSheetState({
@@ -319,20 +311,13 @@ export function ProvidersWorkbenchPage() {
const handleDelete = useCallback(
(resource: ProviderResource) => {
const isAmpcode = resource.brand === 'ampcode';
const name =
resource.name ?? resource.apiKeyPreview ?? resource.identifier ?? '';
showConfirmation({
title: isAmpcode
? t('providersPage.delete.ampcodeTitle')
: t('providersPage.delete.title'),
message: isAmpcode
? t('providersPage.delete.ampcodeConfirm')
: t('providersPage.delete.confirm', { name }),
title: t('providersPage.delete.title'),
message: t('providersPage.delete.confirm', { name }),
variant: 'danger',
confirmText: isAmpcode
? t('providersPage.actions.clear')
: t('providersPage.actions.delete'),
confirmText: t('providersPage.actions.delete'),
onConfirm: async () => {
try {
await workbench.deleteProvider(resource);
@@ -408,8 +393,6 @@ export function ProvidersWorkbenchPage() {
);
}
const ampcodeBrandActive = activeBrand === 'ampcode';
return (
<div className={styles.page}>
<ProviderHeaderCard
@@ -418,12 +401,8 @@ export function ProvidersWorkbenchPage() {
providerFamilies={providerFamilies}
updatedAtLabel={updatedAtLabel}
isFetching={workbench.isFetching}
isNewDisabled={disableMutations && !ampcodeBrandActive}
newLabel={
ampcodeBrandActive
? t('providersPage.actions.edit')
: t('providersPage.actions.new')
}
isNewDisabled={disableMutations}
newLabel={t('providersPage.actions.new')}
onRefresh={() => void handleRefresh()}
onNew={openCreate}
/>
+1 -51
View File
@@ -1,9 +1,4 @@
import type {
AmpcodeConfig,
GeminiKeyConfig,
OpenAIProviderConfig,
ProviderKeyConfig,
} from '@/types';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import {
hasDisableAllModelsRule,
stripDisableAllModelsRule,
@@ -27,17 +22,6 @@ const collectModelNames = (models?: Array<{ name?: string }>): string[] => {
return Array.from(seen);
};
const collectAmpcodeModelNames = (mappings: AmpcodeConfig['modelMappings']): string[] => {
const seen = new Set<string>();
(mappings ?? []).forEach((mapping) => {
const from = (mapping?.from ?? '').trim();
const to = (mapping?.to ?? '').trim();
if (from) seen.add(from);
if (to) seen.add(to);
});
return Array.from(seen);
};
const normalizePriority = (priority?: number): number =>
typeof priority === 'number' && Number.isFinite(priority) ? priority : 0;
@@ -146,37 +130,3 @@ export function openaiToResource(
raw: config,
};
}
export function ampcodeToResource(config?: AmpcodeConfig | null): ProviderResource {
const safe: AmpcodeConfig = config ?? {};
const upstreamApiKey = safe.upstreamApiKey ?? '';
const upstreamUrl = (safe.upstreamUrl ?? '').trim();
const hasUpstream = upstreamUrl.length > 0;
const upstreamKeyMappingsCount = safe.upstreamApiKeys?.length ?? 0;
return {
id: 'ampcode:singleton',
brand: 'ampcode',
originalIndex: 0,
name: null,
identifier: 'Amp CLI',
apiKeyPreview: upstreamApiKey ? maskApiKey(upstreamApiKey) : null,
apiKey: upstreamApiKey || null,
authIndex: null,
baseUrl: upstreamUrl || null,
proxyUrl: null,
prefix: null,
modelCount: safe.modelMappings?.length ?? 0,
models: collectAmpcodeModelNames(safe.modelMappings),
priority: 0,
headerCount: 0,
excludedModelCount: 0,
apiKeyEntryCount: upstreamKeyMappingsCount,
disabled: !hasUpstream,
flags: {
forceModelMappings: safe.forceModelMappings === true,
isPlaceholder: !hasUpstream && upstreamKeyMappingsCount === 0,
},
selector: { brand: 'ampcode' },
raw: safe,
};
}
-2
View File
@@ -1,4 +1,3 @@
import ampcodeLogo from '@/assets/icons/amp.svg';
import claudeLogo from '@/assets/icons/claude.svg';
import codexLogo from '@/assets/icons/codex.svg';
import geminiLogo from '@/assets/icons/gemini.svg';
@@ -17,5 +16,4 @@ export const PROVIDER_LOGOS: Record<ProviderBrand, ProviderBrandLogo> = {
codex: { src: codexLogo },
vertex: { src: vertexLogo },
openaiCompatibility: { src: openaiLogo, invertOnDark: true },
ampcode: { src: ampcodeLogo },
};
@@ -25,7 +25,7 @@ export function ProviderCategoryList({
const realResources = group.resources.filter(
(r) => !r.flags.isPlaceholder
);
const total = realResources.length || (group.id === 'ampcode' ? 1 : 0);
const total = realResources.length;
const activeCount = realResources.filter((r) => !r.disabled).length;
const logo = PROVIDER_LOGOS[group.id];
const itemClass = `${styles.item} ${active ? styles.active : ''}`;
@@ -52,25 +52,19 @@ export function ProviderCategoryList({
{t(`providersPage.providerNames.${group.id}`)}
</span>
<span className={styles.itemSubtitle}>
{group.id === 'ampcode'
? t(
group.resources[0]?.disabled
? 'providersPage.categories.ampcodeInactive'
: 'providersPage.categories.ampcodeActive'
)
: t('providersPage.categories.activeCount', {
active: activeCount,
total,
})}
{t('providersPage.categories.activeCount', {
active: activeCount,
total,
})}
</span>
</span>
</span>
<span
className={`${styles.badge} ${
group.id !== 'ampcode' && total === 0 ? styles.badgeAmber : ''
total === 0 ? styles.badgeAmber : ''
}`}
>
{group.id === 'ampcode' ? (group.resources[0]?.disabled ? '—' : '1') : total}
{total}
</span>
</button>
);
@@ -73,20 +73,18 @@ export function ProviderResourcePanel({
</h2>
</div>
</div>
{group.id !== 'ampcode' ? (
<div className={styles.searchWrap}>
<span className={styles.searchIcon} aria-hidden="true">
<IconSearch size={16} />
</span>
<input
type="search"
className={styles.searchInput}
value={filter}
onChange={(event) => onFilterChange(event.target.value)}
placeholder={t('providersPage.table.filterPlaceholder')}
/>
</div>
) : null}
<div className={styles.searchWrap}>
<span className={styles.searchIcon} aria-hidden="true">
<IconSearch size={16} />
</span>
<input
type="search"
className={styles.searchInput}
value={filter}
onChange={(event) => onFilterChange(event.target.value)}
placeholder={t('providersPage.table.filterPlaceholder')}
/>
</div>
</div>
{toolbarControls ? (
<div className={styles.headerToolbarRow}>
@@ -104,7 +102,7 @@ export function ProviderResourcePanel({
) : null}
</div>
{realResources.length === 0 && group.id !== 'ampcode' ? (
{realResources.length === 0 ? (
<div className={styles.empty}>
<div>{t('providersPage.table.empty')}</div>
<div className={styles.emptyAction}>
@@ -112,11 +112,6 @@ export function ProviderResourceTable({
renderMetric('keys', t('providersPage.table.metrics.keys'), r.apiKeyEntryCount),
renderMetric('headers', t('providersPage.table.metrics.headers'), r.headerCount),
);
} else if (r.brand === 'ampcode') {
items.push(
renderMetric('mappings', t('providersPage.table.metrics.mappings'), r.modelCount),
renderMetric('keys', t('providersPage.table.metrics.keys'), r.apiKeyEntryCount),
);
} else {
items.push(
renderMetric('models', t('providersPage.table.metrics.models'), r.modelCount),
@@ -133,14 +128,6 @@ export function ProviderResourceTable({
};
const renderStatus = (r: ProviderResource) => {
if (r.brand === 'ampcode' && r.flags.isPlaceholder) {
return (
<span className={`${styles.statusBadge} ${styles.statusDisabled}`}>
<IconAlertTriangle size={14} />
{t('providersPage.status.notConfigured')}
</span>
);
}
if (r.disabled) {
return (
<span className={`${styles.statusBadge} ${styles.statusDisabled}`}>
@@ -169,16 +156,6 @@ export function ProviderResourceTable({
</div>
);
}
if (r.brand === 'ampcode') {
return (
<div className={styles.primaryCell}>
<span className={styles.primaryName}>Amp CLI</span>
<span className={styles.primarySub}>
{r.apiKeyPreview ?? t('providersPage.table.noFallbackKey')}
</span>
</div>
);
}
return (
<div className={styles.primaryCell}>
<span className={styles.primaryName}>{r.apiKeyPreview ?? '—'}</span>
@@ -197,9 +174,6 @@ export function ProviderResourceTable({
</span>
);
}
if (r.brand === 'ampcode' && !r.baseUrl) {
return <span className={styles.baseUrl}>{t('providersPage.status.notConfigured')}</span>;
}
return (
<span className={styles.baseUrl}>
{r.baseUrl ?? t('providersPage.status.notSet')}
@@ -225,15 +199,12 @@ export function ProviderResourceTable({
</TableHeader>
<TableBody>
{resources.map((resource) => {
const isAmpcode = resource.brand === 'ampcode';
return (
<TableRow key={resource.id} selected={resource.id === selectedId}>
<TableCell>{renderPrimary(resource)}</TableCell>
<TableCell>{renderBaseUrl(resource)}</TableCell>
<TableCell>
{resource.brand === 'ampcode' ? (
<span className={styles.baseUrl}></span>
) : resource.prefix ? (
{resource.prefix ? (
<span className={styles.chip}>{resource.prefix}</span>
) : (
<span className={styles.baseUrl}>{t('providersPage.status.none')}</span>
@@ -243,7 +214,7 @@ export function ProviderResourceTable({
<TableCell>
<div className={styles.statusCell}>
{renderStatus(resource)}
{usageByProvider && resource.brand !== 'ampcode' ? (
{usageByProvider ? (
<>
{(() => {
const stats = resolveTotalStats(resource, usageByProvider);
@@ -268,7 +239,7 @@ export function ProviderResourceTable({
</TableCell>
<TableCell alignRight>
<div className={styles.actions}>
{!isAmpcode && onToggleDisabled ? (
{onToggleDisabled ? (
<span
className={styles.toggleWrap}
onClick={(e) => e.stopPropagation()}
@@ -312,35 +283,19 @@ export function ProviderResourceTable({
>
<IconPencil size={16} />
</button>
{isAmpcode ? (
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
aria-label={t('providersPage.actions.clear')}
title={t('providersPage.actions.clear')}
disabled={disableMutations || resource.flags.isPlaceholder}
onClick={(e) => {
e.stopPropagation();
onDelete(resource);
}}
>
<IconTrash2 size={16} />
</button>
) : (
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
aria-label={t('providersPage.actions.delete')}
title={t('providersPage.actions.delete')}
disabled={disableMutations}
onClick={(e) => {
e.stopPropagation();
onDelete(resource);
}}
>
<IconTrash2 size={16} />
</button>
)}
<button
type="button"
className={`${styles.iconBtn} ${styles.iconBtnDanger}`}
aria-label={t('providersPage.actions.delete')}
title={t('providersPage.actions.delete')}
disabled={disableMutations}
onClick={(e) => {
e.stopPropagation();
onDelete(resource);
}}
>
<IconTrash2 size={16} />
</button>
</div>
</TableCell>
</TableRow>
-27
View File
@@ -17,7 +17,6 @@ export interface ProviderDescriptor {
supportsWebsockets: boolean;
supportsCloak: boolean;
supportsApiKeyEntries: boolean;
supportsAmpcodeMappings: boolean;
/** Sheet 默认宽度 */
sheetSize: 'md' | 'lg' | 'xl';
}
@@ -40,7 +39,6 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: false,
sheetSize: 'md',
},
codex: {
@@ -60,7 +58,6 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsWebsockets: true,
supportsCloak: false,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: false,
sheetSize: 'md',
},
claude: {
@@ -80,7 +77,6 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsWebsockets: false,
supportsCloak: true,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: false,
sheetSize: 'md',
},
vertex: {
@@ -100,7 +96,6 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: false,
sheetSize: 'md',
},
openaiCompatibility: {
@@ -120,27 +115,6 @@ export const PROVIDER_DESCRIPTORS: Record<ProviderBrand, ProviderDescriptor> = {
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: true,
supportsAmpcodeMappings: false,
sheetSize: 'lg',
},
ampcode: {
id: 'ampcode',
supportsName: false,
supportsApiKey: false,
supportsDisabled: false,
supportsBaseUrl: true,
baseUrlRequired: false,
supportsProxyUrl: false,
supportsPrefix: false,
supportsModels: false,
supportsHeaders: false,
supportsExcludedModels: false,
supportsPriority: false,
supportsTestModel: false,
supportsWebsockets: false,
supportsCloak: false,
supportsApiKeyEntries: false,
supportsAmpcodeMappings: true,
sheetSize: 'lg',
},
};
@@ -151,5 +125,4 @@ export const PROVIDER_BRAND_ORDER: ProviderBrand[] = [
'claude',
'vertex',
'openaiCompatibility',
'ampcode',
];
@@ -11,7 +11,6 @@ import type {
ProviderResource,
} from '../types';
import type { UseProviderWorkbenchResult } from '../useProviderWorkbench';
import { AmpcodeForm } from './forms/AmpcodeForm';
import { BaseProviderForm } from './forms/BaseProviderForm';
import { ResourceDetailView } from './ResourceDetailView';
import styles from './forms/sharedForm.module.scss';
@@ -70,7 +69,6 @@ export function ProviderSheet({
}, []);
const descriptor = PROVIDER_DESCRIPTORS[state.brand];
const isAmpcode = state.brand === 'ampcode';
const isEditingForm = state.mode === 'create' || state.mode === 'edit';
const formMutating = submitting || mutationDisabled;
const submitDisabled = formMutating || (state.mode === 'edit' && !isDirty);
@@ -141,20 +139,6 @@ export function ProviderSheet({
[isDirty, mutationDisabled, onUpdated, state.resource, workbench]
);
const handleAmpcodeSubmit = useCallback(
async (config: Parameters<UseProviderWorkbenchResult['saveAmpcode']>[0]) => {
if (mutationDisabled || !isDirty) return;
setSubmitting(true);
try {
await workbench.saveAmpcode(config);
onUpdated();
} finally {
setSubmitting(false);
}
},
[isDirty, mutationDisabled, onUpdated, workbench]
);
const renderBody = () => {
if (state.mode === 'detail') {
if (!state.resource) {
@@ -163,22 +147,10 @@ export function ProviderSheet({
return <ResourceDetailView resource={state.resource} usageByProvider={usageByProvider} />;
}
const formKey = `${state.brand}:${state.resource?.id ?? 'new'}:${state.mode}`;
if (isAmpcode) {
return (
<AmpcodeForm
key={formKey}
resource={state.resource}
mutating={formMutating}
formId={formId}
onSubmit={handleAmpcodeSubmit}
onDirtyChange={handleDirtyChange}
/>
);
}
return (
<BaseProviderForm
key={formKey}
brand={state.brand as Exclude<ProviderBrand, 'ampcode'>}
brand={state.brand}
resource={state.resource}
mode={state.mode}
mutating={formMutating}
@@ -1,315 +0,0 @@
import { useEffect, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Collapsible } from '@/components/ui/Collapsible';
import { IconPlus, IconX } from '@/components/ui/icons';
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping } from '@/types';
import type { ProviderResource } from '../../types';
import styles from './sharedForm.module.scss';
interface AmpcodeFormState {
upstreamUrl: string;
upstreamApiKey: string;
forceModelMappings: boolean;
upstreamMappings: Array<{ upstreamApiKey: string; clientKeysText: string }>;
modelMappings: Array<{ from: string; to: string }>;
}
const emptyUpstream = () => ({ upstreamApiKey: '', clientKeysText: '' });
const emptyModelMapping = () => ({ from: '', to: '' });
function buildState(config?: AmpcodeConfig | null): AmpcodeFormState {
const safe = config ?? {};
const upstreamMappings = (safe.upstreamApiKeys ?? []).length
? (safe.upstreamApiKeys ?? []).map((m) => ({
upstreamApiKey: m.upstreamApiKey ?? '',
clientKeysText: (m.apiKeys ?? []).join('\n'),
}))
: [emptyUpstream()];
const modelMappings = (safe.modelMappings ?? []).length
? (safe.modelMappings ?? []).map((m) => ({ from: m.from ?? '', to: m.to ?? '' }))
: [emptyModelMapping()];
return {
upstreamUrl: safe.upstreamUrl ?? '',
upstreamApiKey: '',
forceModelMappings: safe.forceModelMappings === true,
upstreamMappings,
modelMappings,
};
}
const parseClientKeys = (text: string): string[] =>
text
.split(/[\n,]+/)
.map((s) => s.trim())
.filter(Boolean);
interface AmpcodeFormProps {
resource: ProviderResource | null;
mutating: boolean;
formId: string;
onSubmit: (config: AmpcodeConfig) => Promise<void>;
onDirtyChange?: (dirty: boolean) => void;
}
export function AmpcodeForm({
resource,
mutating,
formId,
onSubmit,
onDirtyChange,
}: AmpcodeFormProps) {
const { t } = useTranslation();
const fid = useId();
const initialConfig = (resource?.raw as AmpcodeConfig | undefined) ?? {};
const [form, setForm] = useState<AmpcodeFormState>(() => buildState(initialConfig));
const [initialFormSignature] = useState<string>(() => JSON.stringify(buildState(initialConfig)));
const [error, setError] = useState<string | null>(null);
const isDirty = useMemo(
() => JSON.stringify(form) !== initialFormSignature,
[form, initialFormSignature]
);
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
try {
setError(null);
const upstreamApiKeys: AmpcodeUpstreamApiKeyMapping[] = [];
const seen = new Set<string>();
form.upstreamMappings.forEach((m) => {
const key = m.upstreamApiKey.trim();
if (!key || seen.has(key)) return;
const clientKeys = parseClientKeys(m.clientKeysText);
if (!clientKeys.length) return;
seen.add(key);
upstreamApiKeys.push({ upstreamApiKey: key, apiKeys: clientKeys });
});
const modelMappings: AmpcodeModelMapping[] = [];
const seenFrom = new Set<string>();
form.modelMappings.forEach((m) => {
const from = m.from.trim();
const to = m.to.trim();
if (!from || !to) return;
const id = from.toLowerCase();
if (seenFrom.has(id)) return;
seenFrom.add(id);
modelMappings.push({ from, to });
});
const next: AmpcodeConfig = {
upstreamUrl: form.upstreamUrl.trim() || undefined,
upstreamApiKey:
form.upstreamApiKey.trim() || initialConfig.upstreamApiKey?.trim() || undefined,
upstreamApiKeys: upstreamApiKeys.length ? upstreamApiKeys : undefined,
modelMappings: modelMappings.length ? modelMappings : undefined,
forceModelMappings: form.forceModelMappings,
};
await onSubmit(next);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
return (
<form id={formId} className={styles.form} onSubmit={handleSubmit} noValidate>
<div className={styles.section}>
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-url`}>
{t('providersPage.ampcode.upstreamUrl')}
</label>
<input
id={`${fid}-url`}
className={styles.input}
value={form.upstreamUrl}
onChange={(e) => setForm((s) => ({ ...s, upstreamUrl: e.target.value }))}
placeholder="https://api.ampcode.com"
disabled={mutating}
/>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-key`}>
{t('providersPage.ampcode.upstreamApiKey')}
<span className={styles.labelHint}>
{' '}
· {t('providersPage.ampcode.upstreamApiKeyHint')}
</span>
</label>
<input
id={`${fid}-key`}
className={styles.input}
type="password"
value={form.upstreamApiKey}
onChange={(e) => setForm((s) => ({ ...s, upstreamApiKey: e.target.value }))}
autoComplete="new-password"
data-1p-ignore="true"
data-lpignore="true"
data-bwignore="true"
disabled={mutating}
/>
</div>
<label className={styles.checkboxRow}>
<input
type="checkbox"
className={styles.checkboxBox}
checked={form.forceModelMappings}
disabled={mutating}
onChange={(e) => setForm((s) => ({ ...s, forceModelMappings: e.target.checked }))}
/>
<span className={styles.checkboxText}>
<span>{t('providersPage.ampcode.forceModelMappings')}</span>
<small>{t('providersPage.ampcode.forceModelMappingsHint')}</small>
</span>
</label>
</div>
<Collapsible label={t('providersPage.ampcode.keyMappingsSection')} defaultOpen>
<div className={styles.entriesList}>
{form.upstreamMappings.map((m, idx) => (
<div key={idx} className={styles.entryCard}>
<div className={styles.entryCardHeader}>
<span>{t('providersPage.ampcode.mappingRow', { index: idx + 1 })}</span>
<button
type="button"
className={styles.removeBtn}
disabled={mutating || form.upstreamMappings.length <= 1}
onClick={() =>
setForm((s) => ({
...s,
upstreamMappings: s.upstreamMappings.filter((_, i) => i !== idx),
}))
}
>
<IconX size={12} />
</button>
</div>
<div className={styles.field}>
<label className={styles.label}>{t('providersPage.ampcode.upstreamApiKey')}</label>
<input
className={styles.input}
value={m.upstreamApiKey}
onChange={(e) =>
setForm((s) => ({
...s,
upstreamMappings: s.upstreamMappings.map((it, i) =>
i === idx ? { ...it, upstreamApiKey: e.target.value } : it
),
}))
}
disabled={mutating}
/>
</div>
<div className={styles.field}>
<label className={styles.label}>
{t('providersPage.ampcode.clientKeys')}
<span className={styles.labelHint}>
{' '}
· {t('providersPage.ampcode.clientKeysHint')}
</span>
</label>
<textarea
className={styles.textarea}
rows={3}
value={m.clientKeysText}
onChange={(e) =>
setForm((s) => ({
...s,
upstreamMappings: s.upstreamMappings.map((it, i) =>
i === idx ? { ...it, clientKeysText: e.target.value } : it
),
}))
}
disabled={mutating}
/>
</div>
</div>
))}
<button
type="button"
className={styles.addBtn}
disabled={mutating}
onClick={() =>
setForm((s) => ({
...s,
upstreamMappings: [...s.upstreamMappings, emptyUpstream()],
}))
}
>
<IconPlus size={12} />
<span>{t('providersPage.ampcode.addMapping')}</span>
</button>
</div>
</Collapsible>
<Collapsible label={t('providersPage.ampcode.modelMappingsSection')}>
<div className={styles.entriesList}>
{form.modelMappings.map((m, idx) => (
<div key={idx} style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8 }}>
<input
className={styles.input}
placeholder="from"
value={m.from}
onChange={(e) =>
setForm((s) => ({
...s,
modelMappings: s.modelMappings.map((it, i) =>
i === idx ? { ...it, from: e.target.value } : it
),
}))
}
disabled={mutating}
/>
<input
className={styles.input}
placeholder="to"
value={m.to}
onChange={(e) =>
setForm((s) => ({
...s,
modelMappings: s.modelMappings.map((it, i) =>
i === idx ? { ...it, to: e.target.value } : it
),
}))
}
disabled={mutating}
/>
<button
type="button"
className={styles.removeBtn}
disabled={mutating || form.modelMappings.length <= 1}
onClick={() =>
setForm((s) => ({
...s,
modelMappings: s.modelMappings.filter((_, i) => i !== idx),
}))
}
>
<IconX size={12} />
</button>
</div>
))}
<button
type="button"
className={styles.addBtn}
disabled={mutating}
onClick={() =>
setForm((s) => ({
...s,
modelMappings: [...s.modelMappings, emptyModelMapping()],
}))
}
>
<IconPlus size={12} />
<span>{t('providersPage.ampcode.addModelMapping')}</span>
</button>
</div>
</Collapsible>
{error ? <div className={styles.errorBox}>{error}</div> : null}
</form>
);
}
@@ -37,7 +37,7 @@ export interface BaseProviderFormHandle {
}
interface BaseProviderFormProps {
brand: Exclude<ProviderBrand, 'ampcode'>;
brand: ProviderBrand;
resource: ProviderResource | null;
mode: 'create' | 'edit';
mutating: boolean;
@@ -62,7 +62,7 @@ const formatJsonObject = (value?: Record<string, unknown>): string => {
};
function buildInitialForm(
brand: Exclude<ProviderBrand, 'ampcode'>,
brand: ProviderBrand,
resource: ProviderResource | null,
mode: 'create' | 'edit'
): ProviderEntryFormInput {
+4 -7
View File
@@ -7,8 +7,7 @@ export type ProviderBrand =
| 'codex'
| 'claude'
| 'vertex'
| 'openaiCompatibility'
| 'ampcode';
| 'openaiCompatibility';
export const PROVIDER_SORT_BY_VALUES = ['name', 'priority', 'recent-success'] as const;
export type ProviderSortBy = (typeof PROVIDER_SORT_BY_VALUES)[number];
@@ -21,13 +20,11 @@ export type ProviderResourceSelector =
| { brand: 'codex'; apiKey: string; baseUrl?: string; index: number }
| { brand: 'claude'; apiKey: string; baseUrl?: string; index: number }
| { brand: 'vertex'; apiKey: string; baseUrl?: string; index: number }
| { brand: 'openaiCompatibility'; name: string; index: number }
| { brand: 'ampcode' };
| { brand: 'openaiCompatibility'; name: string; index: number };
export interface ProviderResourceFlags {
cloakEnabled?: boolean;
websockets?: boolean;
forceModelMappings?: boolean;
isPlaceholder?: boolean;
}
@@ -35,7 +32,7 @@ export interface ProviderResource {
/** 稳定 id,用作 React key 与选中态判断 */
id: string;
brand: ProviderBrand;
/** 在原数组中的下标。Ampcode 永远为 0 */
/** 在原数组中的下标 */
originalIndex: number;
/** 表格 key 列显示名(OpenAI=name,其余=null) */
name: string | null;
@@ -50,7 +47,7 @@ export interface ProviderResource {
proxyUrl: string | null;
prefix: string | null;
modelCount: number;
/** 去重后的模型名(ampcode 为映射两端), 供筛选/搜索用 */
/** 去重后的模型名, 供筛选/搜索用 */
models: string[];
/** 排序用优先级,未配置时为 0 */
priority: number;
+2 -68
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ampcodeApi, providersApi } from '@/services/api';
import { providersApi } from '@/services/api';
import { getErrorMessage } from '@/utils/helpers';
import { useAuthStore, useConfigStore } from '@/stores';
import {
@@ -7,13 +7,11 @@ import {
withoutDisableAllModelsRule,
} from '@/components/providers/utils';
import type {
AmpcodeConfig,
GeminiKeyConfig,
OpenAIProviderConfig,
ProviderKeyConfig,
} from '@/types';
import {
ampcodeToResource,
claudeToResource,
codexToResource,
geminiToResource,
@@ -42,7 +40,6 @@ export interface UseProviderWorkbenchResult {
updateProvider: (resource: ProviderResource, input: ProviderEntryFormInput) => Promise<void>;
deleteProvider: (resource: ProviderResource) => Promise<void>;
toggleDisabled: (resource: ProviderResource, disabled: boolean) => Promise<void>;
saveAmpcode: (config: AmpcodeConfig) => Promise<void>;
mutating: boolean;
refreshSnapshot: () => void;
}
@@ -208,10 +205,9 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
setIsFetching(true);
setErrorMessage(null);
try {
const [configResult, vertexResult, ampcodeResult, openaiResult] = await Promise.allSettled([
const [configResult, vertexResult, openaiResult] = await Promise.allSettled([
fetchConfig(undefined, true),
providersApi.getVertexConfigs(),
ampcodeApi.getAmpcode(),
providersApi.getOpenAIProviders(),
]);
if (configResult.status !== 'fulfilled') {
@@ -220,9 +216,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
if (vertexResult.status === 'fulfilled') {
updateConfigValue('vertex-api-key', vertexResult.value || []);
}
if (ampcodeResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcodeResult.value);
}
if (openaiResult.status === 'fulfilled') {
updateConfigValue('openai-compatibility', openaiResult.value || []);
}
@@ -268,9 +261,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
case 'openaiCompatibility':
resources = (config.openaiCompatibility ?? []).map((c, i) => openaiToResource(c, i));
break;
case 'ampcode':
resources = [ampcodeToResource(config.ampcode)];
break;
}
return {
id: brand,
@@ -349,8 +339,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
const next = [...(config?.openaiCompatibility ?? [])];
next.push(buildOpenAIConfig(input));
await persistOpenAIConfigs(next);
} else if (brand === 'ampcode') {
throw new Error('Use saveAmpcode for ampcode create/update');
}
refreshSnapshot();
} finally {
@@ -399,8 +387,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
const existing = list[idx];
list[idx] = buildOpenAIConfig(input, existing);
await persistOpenAIConfigs(list);
} else if (brand === 'ampcode') {
throw new Error('Use saveAmpcode for ampcode update');
}
refreshSnapshot();
} finally {
@@ -443,13 +429,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
await providersApi.deleteOpenAIProvider(sel.index);
const next = (config?.openaiCompatibility ?? []).filter((_, i) => i !== sel.index);
updateConfigValue('openai-compatibility', next);
} else if (sel.brand === 'ampcode') {
await Promise.allSettled([
ampcodeApi.clearUpstreamUrl(),
ampcodeApi.clearUpstreamApiKey(),
ampcodeApi.clearModelMappings(),
]);
updateConfigValue('ampcode', {});
}
refreshSnapshot();
} finally {
@@ -499,8 +478,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
list[idx] = { ...current, disabled };
updateConfigValue('openai-compatibility', list);
}
} else if (brand === 'ampcode') {
/* ampcode toggle 不支持,跳过 */
}
refreshSnapshot();
} finally {
@@ -518,48 +495,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
]
);
const saveAmpcode = useCallback(
async (next: AmpcodeConfig) => {
setMutating(true);
try {
// 细粒度 PUT 序列以保留兼容性
const url = (next.upstreamUrl ?? '').trim();
if (url) {
await ampcodeApi.updateUpstreamUrl(url);
} else {
await ampcodeApi.clearUpstreamUrl();
}
const fallbackKey = (next.upstreamApiKey ?? '').trim();
if (fallbackKey) {
await ampcodeApi.updateUpstreamApiKey(fallbackKey);
} else {
await ampcodeApi.clearUpstreamApiKey();
}
if (Array.isArray(next.upstreamApiKeys) && next.upstreamApiKeys.length) {
await ampcodeApi.saveUpstreamApiKeys(next.upstreamApiKeys);
} else {
await ampcodeApi.saveUpstreamApiKeys([]);
}
if (Array.isArray(next.modelMappings) && next.modelMappings.length) {
await ampcodeApi.saveModelMappings(next.modelMappings);
} else {
await ampcodeApi.clearModelMappings();
}
await ampcodeApi.updateForceModelMappings(next.forceModelMappings === true);
updateConfigValue('ampcode', next);
refreshSnapshot();
} finally {
setMutating(false);
}
},
[updateConfigValue, refreshSnapshot]
);
return {
connected,
isPending,
@@ -572,7 +507,6 @@ export function useProviderWorkbench(): UseProviderWorkbenchResult {
updateProvider,
deleteProvider,
toggleDisabled,
saveAmpcode,
mutating,
refreshSnapshot,
};
+5 -28
View File
@@ -148,7 +148,7 @@
"quick_actions": "Quick Actions",
"current_config": "Current Configuration",
"management_keys": "Management Keys",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}}",
"oauth_credentials": "OAuth Credentials",
"edit_settings": "Edit Settings",
"routing_strategy": "Routing Strategy",
@@ -1309,8 +1309,6 @@
"openai_provider_added": "OpenAI provider added successfully",
"openai_provider_updated": "OpenAI provider updated successfully",
"openai_provider_deleted": "OpenAI provider deleted successfully",
"ampcode_updated": "Ampcode configuration updated",
"ampcode_upstream_api_key_cleared": "Ampcode upstream API key override cleared",
"openai_model_name_required": "Model name is required",
"openai_test_url_required": "Please provide a valid Base URL before testing",
"openai_test_key_required": "Please add at least one API key before testing",
@@ -1385,17 +1383,14 @@
},
"categories": {
"title": "Providers",
"activeCount": "{{active}}/{{total}} active",
"ampcodeActive": "Connected",
"ampcodeInactive": "Not configured"
"activeCount": "{{active}}/{{total}} active"
},
"providerNames": {
"gemini": "Gemini",
"codex": "Codex",
"claude": "Claude",
"vertex": "Vertex",
"openaiCompatibility": "OpenAI Compatible",
"ampcode": "Amp CLI"
"openaiCompatibility": "OpenAI Compatible"
},
"table": {
"key": "Key",
@@ -1407,12 +1402,10 @@
"metrics": {
"models": "Models",
"keys": "Keys",
"headers": "Headers",
"mappings": "Mappings"
"headers": "Headers"
},
"websocketsTag": "WebSockets",
"cloakTag": "Cloak",
"noFallbackKey": "No fallback key",
"empty": "No resources yet, click \"New\" to add.",
"filterPlaceholder": "Search keys, URLs, prefixes…",
"description": "Manage resources under {{route}}",
@@ -1495,25 +1488,9 @@
"baseUrlRequired": "Base URL is required"
}
},
"ampcode": {
"upstreamUrl": "Upstream URL",
"upstreamApiKey": "Upstream API key (fallback)",
"upstreamApiKeyHint": "Used when no key mapping matches",
"keyMappingsSection": "Upstream key mappings",
"mappingRow": "Mapping #{{index}}",
"clientKeys": "Client keys",
"clientKeysHint": "One per line; matched keys forward to the upstream key above",
"addMapping": "Add key mapping",
"modelMappingsSection": "Model mappings",
"addModelMapping": "Add model mapping",
"forceModelMappings": "Force model mappings",
"forceModelMappingsHint": "Requests that don't match a mapping are rejected"
},
"delete": {
"title": "Delete resource",
"confirm": "Delete {{name}}? This action cannot be undone.",
"ampcodeTitle": "Clear Amp CLI configuration",
"ampcodeConfirm": "Clear all Amp CLI configuration? Upstream URL, keys and model mappings will be removed."
"confirm": "Delete {{name}}? This action cannot be undone."
},
"toast": {
"created": "Created",
+5 -28
View File
@@ -147,7 +147,7 @@
"quick_actions": "Быстрые действия",
"current_config": "Текущая конфигурация",
"management_keys": "Ключи управления",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}}",
"oauth_credentials": "Учётные данные OAuth",
"edit_settings": "Изменить настройки",
"routing_strategy": "Стратегия маршрутизации",
@@ -1286,8 +1286,6 @@
"openai_provider_added": "Провайдер OpenAI успешно добавлен",
"openai_provider_updated": "Провайдер OpenAI успешно обновлён",
"openai_provider_deleted": "Провайдер OpenAI успешно удалён",
"ampcode_updated": "Настройки Ampcode обновлены",
"ampcode_upstream_api_key_cleared": "Переопределение upstream-ключа Ampcode очищено",
"openai_model_name_required": "Введите имя модели",
"openai_test_url_required": "Укажите корректный базовый URL перед тестированием",
"openai_test_key_required": "Добавьте хотя бы один API-ключ перед тестированием",
@@ -1362,17 +1360,14 @@
},
"categories": {
"title": "Провайдеры",
"activeCount": "{{active}}/{{total}} активных",
"ampcodeActive": "Подключено",
"ampcodeInactive": "Не настроено"
"activeCount": "{{active}}/{{total}} активных"
},
"providerNames": {
"gemini": "Gemini",
"codex": "Codex",
"claude": "Claude",
"vertex": "Vertex",
"openaiCompatibility": "OpenAI-совместимый",
"ampcode": "Amp CLI"
"openaiCompatibility": "OpenAI-совместимый"
},
"table": {
"key": "Ключ",
@@ -1384,12 +1379,10 @@
"metrics": {
"models": "Модели",
"keys": "Ключи",
"headers": "Заголовки",
"mappings": "Сопоставления"
"headers": "Заголовки"
},
"websocketsTag": "WebSockets",
"cloakTag": "Cloak",
"noFallbackKey": "Резервный ключ не задан",
"empty": "Нет ресурсов, нажмите \"Создать\".",
"filterPlaceholder": "Поиск по ключам, URL, префиксам…",
"description": "Управление ресурсами {{route}}",
@@ -1472,25 +1465,9 @@
"baseUrlRequired": "Base URL обязателен"
}
},
"ampcode": {
"upstreamUrl": "Upstream URL",
"upstreamApiKey": "Резервный API-ключ",
"upstreamApiKeyHint": "Используется, когда нет совпадений",
"keyMappingsSection": "Сопоставление ключей",
"mappingRow": "Сопоставление #{{index}}",
"clientKeys": "Клиентские ключи",
"clientKeysHint": "По одному в строке; совпавшие пересылаются на ключ выше",
"addMapping": "Добавить сопоставление",
"modelMappingsSection": "Сопоставление моделей",
"addModelMapping": "Добавить сопоставление",
"forceModelMappings": "Принудительное сопоставление",
"forceModelMappingsHint": "Запросы без совпадений отклоняются"
},
"delete": {
"title": "Удалить ресурс",
"confirm": "Удалить {{name}}? Действие необратимо.",
"ampcodeTitle": "Очистить настройки Amp CLI",
"ampcodeConfirm": "Удалить все настройки Amp CLI? Upstream URL, ключи и сопоставления будут удалены."
"confirm": "Удалить {{name}}? Действие необратимо."
},
"toast": {
"created": "Создано",
+5 -28
View File
@@ -148,7 +148,7 @@
"quick_actions": "快捷操作",
"current_config": "当前配置",
"management_keys": "管理密钥",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}}",
"oauth_credentials": "OAuth 凭证",
"edit_settings": "编辑设置",
"routing_strategy": "路由策略",
@@ -1309,8 +1309,6 @@
"openai_provider_added": "OpenAI提供商添加成功",
"openai_provider_updated": "OpenAI提供商更新成功",
"openai_provider_deleted": "OpenAI提供商删除成功",
"ampcode_updated": "Ampcode 配置已更新",
"ampcode_upstream_api_key_cleared": "Ampcode upstream API key 覆盖已清除",
"openai_model_name_required": "请填写模型名称",
"openai_test_url_required": "请先填写有效的 Base URL 以进行测试",
"openai_test_key_required": "请至少填写一个 API 密钥以进行测试",
@@ -1385,17 +1383,14 @@
},
"categories": {
"title": "提供商",
"activeCount": "{{active}}/{{total}} 活跃",
"ampcodeActive": "已连接",
"ampcodeInactive": "未配置"
"activeCount": "{{active}}/{{total}} 活跃"
},
"providerNames": {
"gemini": "Gemini",
"codex": "Codex",
"claude": "Claude",
"vertex": "Vertex",
"openaiCompatibility": "OpenAI 兼容",
"ampcode": "Amp CLI"
"openaiCompatibility": "OpenAI 兼容"
},
"table": {
"key": "密钥",
@@ -1407,12 +1402,10 @@
"metrics": {
"models": "模型",
"keys": "密钥",
"headers": "请求头",
"mappings": "映射"
"headers": "请求头"
},
"websocketsTag": "WebSockets",
"cloakTag": "Cloak",
"noFallbackKey": "未设置兜底密钥",
"empty": "尚未添加配置,点击右上角新建。",
"filterPlaceholder": "搜索密钥、地址、前缀…",
"description": "在 {{route}} 下管理资源",
@@ -1495,25 +1488,9 @@
"baseUrlRequired": "服务地址必填"
}
},
"ampcode": {
"upstreamUrl": "上游 URL",
"upstreamApiKey": "上游 API 密钥(兜底)",
"upstreamApiKeyHint": "未匹配密钥映射时使用",
"keyMappingsSection": "上游密钥映射",
"mappingRow": "映射 #{{index}}",
"clientKeys": "客户端密钥",
"clientKeysHint": "每行一个,匹配后转发到上述上游密钥",
"addMapping": "添加密钥映射",
"modelMappingsSection": "模型映射",
"addModelMapping": "添加模型映射",
"forceModelMappings": "强制模型映射",
"forceModelMappingsHint": "启用后,所有请求必须命中模型映射规则"
},
"delete": {
"title": "删除资源",
"confirm": "确定要删除 {{name}} 吗?此操作不可撤销。",
"ampcodeTitle": "清空 Amp CLI 配置",
"ampcodeConfirm": "确定要清空 Amp CLI 配置吗?上游 URL、API 密钥与模型映射都会被移除。"
"confirm": "确定要删除 {{name}} 吗?此操作不可撤销。"
},
"toast": {
"created": "创建成功",
+5 -28
View File
@@ -148,7 +148,7 @@
"quick_actions": "快速操作",
"current_config": "目前設定",
"management_keys": "管理金鑰",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}} Amp:{{ampcode}}",
"provider_keys_detail": "G:{{gemini}} C:{{codex}} Cl:{{claude}} V:{{vertex}} O:{{openai}}",
"oauth_credentials": "OAuth 憑證",
"edit_settings": "編輯設定",
"routing_strategy": "路由策略",
@@ -1335,8 +1335,6 @@
"openai_provider_added": "OpenAI 供應商新增成功",
"openai_provider_updated": "OpenAI 供應商更新成功",
"openai_provider_deleted": "OpenAI 供應商刪除成功",
"ampcode_updated": "Ampcode 設定已更新",
"ampcode_upstream_api_key_cleared": "Ampcode upstream API key 覆寫已清除",
"openai_model_name_required": "請填寫模型名稱",
"openai_test_url_required": "請先填寫有效的 Base URL 以進行測試",
"openai_test_key_required": "請至少填寫一個 API 金鑰以進行測試",
@@ -1411,17 +1409,14 @@
},
"categories": {
"title": "提供商",
"activeCount": "{{active}}/{{total}} 活躍",
"ampcodeActive": "已連線",
"ampcodeInactive": "未設定"
"activeCount": "{{active}}/{{total}} 活躍"
},
"providerNames": {
"gemini": "Gemini",
"codex": "Codex",
"claude": "Claude",
"vertex": "Vertex",
"openaiCompatibility": "OpenAI 相容",
"ampcode": "Amp CLI"
"openaiCompatibility": "OpenAI 相容"
},
"table": {
"key": "金鑰",
@@ -1433,12 +1428,10 @@
"metrics": {
"models": "模型",
"keys": "金鑰",
"headers": "請求標頭",
"mappings": "映射"
"headers": "請求標頭"
},
"websocketsTag": "WebSockets",
"cloakTag": "Cloak",
"noFallbackKey": "未設定備援金鑰",
"empty": "尚未新增設定,點擊右上角新增。",
"filterPlaceholder": "搜尋金鑰、位址、前綴…",
"description": "在 {{route}} 下管理資源",
@@ -1521,25 +1514,9 @@
"baseUrlRequired": "服務位址必填"
}
},
"ampcode": {
"upstreamUrl": "上游 URL",
"upstreamApiKey": "上游 API 金鑰(備援)",
"upstreamApiKeyHint": "未匹配金鑰映射時使用",
"keyMappingsSection": "上游金鑰映射",
"mappingRow": "映射 #{{index}}",
"clientKeys": "客戶端金鑰",
"clientKeysHint": "每行一個,匹配後轉發到上述上游金鑰",
"addMapping": "新增金鑰映射",
"modelMappingsSection": "模型映射",
"addModelMapping": "新增模型映射",
"forceModelMappings": "強制模型映射",
"forceModelMappingsHint": "啟用後,所有請求必須命中模型映射規則"
},
"delete": {
"title": "刪除資源",
"confirm": "確定要刪除 {{name}} 嗎?此操作不可復原。",
"ampcodeTitle": "清空 Amp CLI 設定",
"ampcodeConfirm": "確定要清空 Amp CLI 設定嗎?上游 URL、API 金鑰與模型映射都會被移除。"
"confirm": "確定要刪除 {{name}} 嗎?此操作不可復原。"
},
"toast": {
"created": "建立成功",
-14
View File
@@ -5,7 +5,6 @@ import { IconKey, IconBot, IconFileText, IconSatellite } from '@/components/ui/i
import { useAuthStore, useConfigStore, useModelsStore } from '@/stores';
import { authFilesApi } from '@/services/api';
import { useApiKeysForModels } from '@/hooks/useApiKeysForModels';
import type { AmpcodeConfig } from '@/types';
import { formatDateValue } from '@/utils/format';
import styles from './DashboardPage.module.scss';
@@ -20,17 +19,6 @@ interface QuickStat {
type TimeOfDay = 'morning' | 'afternoon' | 'evening' | 'night';
const countAmpcodeConfig = (value: AmpcodeConfig | undefined): number => {
if (!value) return 0;
const configured =
Boolean(value.upstreamUrl?.trim()) ||
Boolean(value.upstreamApiKey?.trim()) ||
(value.upstreamApiKeys?.length ?? 0) > 0 ||
(value.modelMappings?.length ?? 0) > 0 ||
value.forceModelMappings === true;
return configured ? 1 : 0;
};
function getTimeOfDay(): TimeOfDay {
const hour = new Date().getHours();
if (hour >= 5 && hour < 12) return 'morning';
@@ -121,7 +109,6 @@ export function DashboardPage() {
claude: config.claudeApiKeys?.length ?? 0,
vertex: config.vertexApiKeys?.length ?? 0,
openai: config.openaiCompatibility?.length ?? 0,
ampcode: countAmpcodeConfig(config.ampcode),
}
: null;
const totalProviderKeys = providerStats
@@ -150,7 +137,6 @@ export function DashboardPage() {
claude: providerStats.claude,
vertex: providerStats.vertex,
openai: providerStats.openai,
ampcode: providerStats.ampcode,
})
: undefined,
},
-59
View File
@@ -1,59 +0,0 @@
/**
* Amp CLI Integration (ampcode) 相关 API
*/
import { apiClient } from './client';
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<AmpcodeConfig> {
const data = await apiClient.get('/ampcode');
return normalizeAmpcodeConfig(data) ?? {};
},
updateUpstreamUrl: (url: string) => apiClient.put('/ampcode/upstream-url', { value: url }),
clearUpstreamUrl: () => apiClient.delete('/ampcode/upstream-url'),
updateUpstreamApiKey: (apiKey: string) => apiClient.put('/ampcode/upstream-api-key', { value: apiKey }),
clearUpstreamApiKey: () => apiClient.delete('/ampcode/upstream-api-key'),
async getUpstreamApiKeys(): Promise<AmpcodeUpstreamApiKeyMapping[]> {
const data = await apiClient.get<Record<string, unknown>>('/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<AmpcodeModelMapping[]> {
const data = await apiClient.get<Record<string, unknown>>('/ampcode/model-mappings');
const list = data?.['model-mappings'] ?? data?.modelMappings ?? data?.items ?? data;
return normalizeAmpcodeModelMappings(list);
},
saveModelMappings: (mappings: AmpcodeModelMapping[]) =>
apiClient.put('/ampcode/model-mappings', { value: mappings }),
patchModelMappings: (mappings: AmpcodeModelMapping[]) =>
apiClient.patch('/ampcode/model-mappings', { value: mappings }),
clearModelMappings: () => apiClient.delete('/ampcode/model-mappings'),
deleteModelMappings: (fromList: string[]) =>
apiClient.delete('/ampcode/model-mappings', { data: { value: fromList } }),
updateForceModelMappings: (enabled: boolean) => apiClient.put('/ampcode/force-model-mappings', { value: enabled })
};
-1
View File
@@ -4,7 +4,6 @@ export * from './apiKeyUsage';
export * from './config';
export * from './configFile';
export * from './apiKeys';
export * from './ampcode';
export * from './providers';
export * from './authFiles';
export * from './oauth';
+2 -86
View File
@@ -4,10 +4,7 @@ import type {
GeminiKeyConfig,
ModelAlias,
OpenAIProviderConfig,
ProviderKeyConfig,
AmpcodeConfig,
AmpcodeModelMapping,
AmpcodeUpstreamApiKeyMapping
ProviderKeyConfig
} from '@/types';
import type { Config } from '@/types/config';
import { buildHeaderObject } from '@/utils/headers';
@@ -272,79 +269,6 @@ const normalizeOauthExcluded = (payload: unknown): Record<string, string[]> | un
return map;
};
const normalizeAmpcodeModelMappings = (input: unknown): AmpcodeModelMapping[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const mappings: AmpcodeModelMapping[] = [];
input.forEach((entry) => {
if (!isRecord(entry)) return;
const from = String(entry.from ?? '').trim();
const to = String(entry.to ?? '').trim();
if (!from || !to) return;
const key = from.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
mappings.push({ from, to });
});
return mappings;
};
const normalizeAmpcodeUpstreamApiKeys = (input: unknown): AmpcodeUpstreamApiKeyMapping[] => {
if (!Array.isArray(input)) return [];
const seen = new Set<string>();
const mappings: AmpcodeUpstreamApiKeyMapping[] = [];
input.forEach((entry) => {
if (!isRecord(entry)) return;
const upstreamApiKey = String(entry['upstream-api-key'] ?? '').trim();
if (!upstreamApiKey || seen.has(upstreamApiKey)) return;
const rawApiKeys = 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;
const source = sourceRaw;
const config: AmpcodeConfig = {};
const upstreamUrl = source['upstream-url'];
if (upstreamUrl) config.upstreamUrl = String(upstreamUrl);
const upstreamApiKey = source['upstream-api-key'];
if (upstreamApiKey) config.upstreamApiKey = String(upstreamApiKey);
const upstreamApiKeys = normalizeAmpcodeUpstreamApiKeys(source['upstream-api-keys']);
if (upstreamApiKeys.length) {
config.upstreamApiKeys = upstreamApiKeys;
}
const forceModelMappings = normalizeBoolean(source['force-model-mappings']);
if (forceModelMappings !== undefined) {
config.forceModelMappings = forceModelMappings;
}
const modelMappings = normalizeAmpcodeModelMappings(source['model-mappings']);
if (modelMappings.length) {
config.modelMappings = modelMappings;
}
return config;
};
/**
* 规范化 /config 返回值
*/
@@ -435,11 +359,6 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
.filter(Boolean) as OpenAIProviderConfig[];
}
const ampcode = normalizeAmpcodeConfig(raw.ampcode);
if (ampcode) {
config.ampcode = ampcode;
}
const oauthExcluded = normalizeOauthExcluded(raw['oauth-excluded-models']);
if (oauthExcluded) {
config.oauthExcludedModels = oauthExcluded;
@@ -455,8 +374,5 @@ export {
normalizeOpenAIProvider,
normalizeProviderKeyConfig,
normalizeHeaders,
normalizeExcludedModels,
normalizeAmpcodeConfig,
normalizeAmpcodeModelMappings,
normalizeAmpcodeUpstreamApiKeys
normalizeExcludedModels
};
-6
View File
@@ -45,7 +45,6 @@ const SECTION_KEYS: RawConfigSection[] = [
'force-model-prefix',
'routing/strategy',
'api-keys',
'ampcode',
'gemini-api-key',
'codex-api-key',
'claude-api-key',
@@ -79,8 +78,6 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
return config.routingStrategy;
case 'api-keys':
return config.apiKeys;
case 'ampcode':
return config.ampcode;
case 'gemini-api-key':
return config.geminiApiKeys;
case 'codex-api-key':
@@ -220,9 +217,6 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
case 'api-keys':
nextConfig.apiKeys = value as Config['apiKeys'];
break;
case 'ampcode':
nextConfig.ampcode = value as Config['ampcode'];
break;
case 'gemini-api-key':
nextConfig.geminiApiKeys = value as Config['geminiApiKeys'];
break;
-21
View File
@@ -1,21 +0,0 @@
/**
* Amp CLI Integration (ampcode) 配置
*/
export interface AmpcodeModelMapping {
from: string;
to: string;
}
export interface AmpcodeUpstreamApiKeyMapping {
upstreamApiKey: string;
apiKeys: string[];
}
export interface AmpcodeConfig {
upstreamUrl?: string;
upstreamApiKey?: string;
upstreamApiKeys?: AmpcodeUpstreamApiKeyMapping[];
modelMappings?: AmpcodeModelMapping[];
forceModelMappings?: boolean;
}
-3
View File
@@ -4,7 +4,6 @@
*/
import type { GeminiKeyConfig, ProviderKeyConfig, OpenAIProviderConfig } from './provider';
import type { AmpcodeConfig } from './ampcode';
export interface QuotaExceededConfig {
switchProject?: boolean;
@@ -24,7 +23,6 @@ export interface Config {
forceModelPrefix?: boolean;
routingStrategy?: string;
apiKeys?: string[];
ampcode?: AmpcodeConfig;
geminiApiKeys?: GeminiKeyConfig[];
codexApiKeys?: ProviderKeyConfig[];
claudeApiKeys?: ProviderKeyConfig[];
@@ -46,7 +44,6 @@ export type RawConfigSection =
| 'force-model-prefix'
| 'routing/strategy'
| 'api-keys'
| 'ampcode'
| 'gemini-api-key'
| 'codex-api-key'
| 'claude-api-key'
-1
View File
@@ -7,7 +7,6 @@ export * from './api';
export * from './config';
export * from './auth';
export * from './provider';
export * from './ampcode';
export * from './authFile';
export * from './oauth';
export * from './log';