Compare commits

...

3 Commits

21 changed files with 508 additions and 40 deletions
@@ -71,6 +71,10 @@ export function AmpcodeSection({
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_model_mappings_count')}:</span>
<span className={styles.fieldValue}>{config?.modelMappings?.length || 0}</span>
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('ai_providers.ampcode_upstream_api_keys_count')}:</span>
<span className={styles.fieldValue}>{config?.upstreamApiKeys?.length || 0}</span>
</div>
{config?.modelMappings?.length ? (
<div className={styles.modelTagList}>
{config.modelMappings.slice(0, 5).map((mapping) => (
@@ -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({
))}
</div>
) : null}
{excludedModels.length ? (
<div className={styles.excludedModelsSection}>
<div className={styles.excludedModelsLabel}>
{t('ai_providers.excluded_models_count', { count: excludedModels.length })}
</div>
<div className={styles.modelTagList}>
{excludedModels.map((model) => (
<span key={model} className={`${styles.modelTag} ${styles.excludedModelTag}`}>
<span className={styles.modelName}>{model}</span>
</span>
))}
</div>
</div>
) : null}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
+8 -1
View File
@@ -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<GeminiKeyConfig, 'headers' | 'models'> & {
@@ -37,9 +43,10 @@ export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
excludedText: string;
};
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
export type VertexFormState = Omit<ProviderKeyConfig, 'headers'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[];
excludedText: string;
};
export interface ProviderSectionProps<TConfig> {
+36 -2
View File
@@ -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<string>();
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),
});
+80 -9
View File
@@ -11,6 +11,7 @@ import type {
AntigravityQuotaState,
AuthFileItem,
ClaudeExtraUsage,
ClaudeProfileResponse,
ClaudeQuotaState,
ClaudeQuotaWindow,
ClaudeUsagePayload,
@@ -29,6 +30,7 @@ import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api
import {
ANTIGRAVITY_QUOTA_URLS,
ANTIGRAVITY_REQUEST_HEADERS,
CLAUDE_PROFILE_URL,
CLAUDE_USAGE_URL,
CLAUDE_REQUEST_HEADERS,
CLAUDE_USAGE_WINDOW_KEYS,
@@ -673,22 +675,69 @@ const buildClaudeQuotaWindows = (
return windows;
};
const CLAUDE_PLAN_TYPE_MAP: Record<string, string> = {
default_claude_max_5x: 'plan_max5',
default_claude_max_20x: 'plan_max20',
default_claude_pro: 'plan_pro',
default_claude_ai: 'plan_free',
};
const parseClaudeProfilePayload = (payload: unknown): ClaudeProfileResponse | null => {
if (payload === undefined || payload === null) return null;
if (typeof payload === 'string') {
const trimmed = payload.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as ClaudeProfileResponse;
} catch {
return null;
}
}
if (typeof payload === 'object') {
return payload as ClaudeProfileResponse;
}
return null;
};
const resolveClaudePlanType = (profile: ClaudeProfileResponse | null): string | null => {
if (!profile) return null;
const tier = normalizeStringValue(profile.organization?.rate_limit_tier);
if (!tier) return null;
return CLAUDE_PLAN_TYPE_MAP[tier] ?? 'plan_unknown';
};
const fetchClaudeQuota = async (
file: AuthFileItem,
t: TFunction
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }> => {
): Promise<{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null; planType?: string | null }> => {
const rawAuthIndex = file['auth_index'] ?? file.authIndex;
const authIndex = normalizeAuthIndex(rawAuthIndex);
if (!authIndex) {
throw new Error(t('claude_quota.missing_auth_index'));
}
const result = await apiCallApi.request({
authIndex,
method: 'GET',
url: CLAUDE_USAGE_URL,
header: { ...CLAUDE_REQUEST_HEADERS },
});
const [usageResult, profileResult] = await Promise.allSettled([
apiCallApi.request({
authIndex,
method: 'GET',
url: CLAUDE_USAGE_URL,
header: { ...CLAUDE_REQUEST_HEADERS },
}),
apiCallApi.request({
authIndex,
method: 'GET',
url: CLAUDE_PROFILE_URL,
header: { ...CLAUDE_REQUEST_HEADERS },
}),
]);
if (usageResult.status === 'rejected') {
throw usageResult.reason;
}
const result = usageResult.value;
if (result.statusCode < 200 || result.statusCode >= 300) {
throw createStatusError(getApiCallErrorMessage(result), result.statusCode);
@@ -700,7 +749,16 @@ const fetchClaudeQuota = async (
}
const windows = buildClaudeQuotaWindows(payload, t);
return { windows, extraUsage: payload.extra_usage };
const planType =
profileResult.status === 'fulfilled' &&
profileResult.value.statusCode >= 200 &&
profileResult.value.statusCode < 300
? resolveClaudePlanType(
parseClaudeProfilePayload(profileResult.value.body ?? profileResult.value.bodyText)
)
: null;
return { windows, extraUsage: payload.extra_usage, planType };
};
const renderClaudeItems = (
@@ -712,8 +770,20 @@ const renderClaudeItems = (
const { createElement: h, Fragment } = React;
const windows = quota.windows ?? [];
const extraUsage = quota.extraUsage ?? null;
const planType = quota.planType ?? null;
const nodes: ReactNode[] = [];
if (planType) {
nodes.push(
h(
'div',
{ key: 'plan', className: styleMap.codexPlan },
h('span', { className: styleMap.codexPlanLabel }, t('claude_quota.plan_label')),
h('span', { className: styleMap.codexPlanValue }, t(`claude_quota.${planType}`))
)
);
}
if (extraUsage && extraUsage.is_enabled) {
const usedLabel = `$${(extraUsage.used_credits / 100).toFixed(2)} / $${(extraUsage.monthly_limit / 100).toFixed(2)}`;
nodes.push(
@@ -765,7 +835,7 @@ const renderClaudeItems = (
export const CLAUDE_CONFIG: QuotaConfig<
ClaudeQuotaState,
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null }
{ windows: ClaudeQuotaWindow[]; extraUsage?: ClaudeExtraUsage | null; planType?: string | null }
> = {
type: 'claude',
i18nPrefix: 'claude_quota',
@@ -779,6 +849,7 @@ export const CLAUDE_CONFIG: QuotaConfig<
status: 'success',
windows: data.windows,
extraUsage: data.extraUsage,
planType: data.planType,
}),
buildErrorState: (message, status) => ({
status: 'error',
@@ -91,6 +91,8 @@ export function AuthFileCard(props: AuthFileCardProps) {
const providerCardClass =
quotaType === 'antigravity'
? styles.antigravityCard
: quotaType === 'claude'
? styles.claudeCard
: quotaType === 'codex'
? styles.codexCard
: quotaType === 'gemini-cli'
@@ -1,7 +1,13 @@
import { useCallback, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, GEMINI_CLI_CONFIG, KIMI_CONFIG } from '@/components/quota';
import {
ANTIGRAVITY_CONFIG,
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CLI_CONFIG,
KIMI_CONFIG
} from '@/components/quota';
import { useNotificationStore, useQuotaStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import { getStatusFromError } from '@/utils/quota';
@@ -17,6 +23,7 @@ type QuotaState = { status?: string; error?: string; errorStatus?: number } | un
const getQuotaConfig = (type: QuotaProviderType) => {
if (type === 'antigravity') return ANTIGRAVITY_CONFIG;
if (type === 'claude') return CLAUDE_CONFIG;
if (type === 'codex') return CODEX_CONFIG;
if (type === 'kimi') return KIMI_CONFIG;
return GEMINI_CLI_CONFIG;
@@ -35,6 +42,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
const quota = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.antigravityQuota[file.name] as QuotaState;
if (quotaType === 'claude') return state.claudeQuota[file.name] as QuotaState;
if (quotaType === 'codex') return state.codexQuota[file.name] as QuotaState;
if (quotaType === 'kimi') return state.kimiQuota[file.name] as QuotaState;
return state.geminiCliQuota[file.name] as QuotaState;
@@ -42,6 +50,7 @@ export function AuthFileQuotaSection(props: AuthFileQuotaSectionProps) {
const updateQuotaState = useQuotaStore((state) => {
if (quotaType === 'antigravity') return state.setAntigravityQuota as unknown as (updater: unknown) => void;
if (quotaType === 'claude') return state.setClaudeQuota as unknown as (updater: unknown) => void;
if (quotaType === 'codex') return state.setCodexQuota as unknown as (updater: unknown) => void;
if (quotaType === 'kimi') return state.setKimiQuota as unknown as (updater: unknown) => void;
return state.setGeminiCliQuota as unknown as (updater: unknown) => void;
+8 -2
View File
@@ -12,9 +12,15 @@ export type TypeColorSet = { light: ThemeColors; dark?: ThemeColors };
export type ResolvedTheme = 'light' | 'dark';
export type AuthFileModelItem = { id: string; display_name?: string; type?: string; owned_by?: string };
export type QuotaProviderType = 'antigravity' | 'codex' | 'gemini-cli' | 'kimi';
export type QuotaProviderType = 'antigravity' | 'claude' | 'codex' | 'gemini-cli' | 'kimi';
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>(['antigravity', 'codex', 'gemini-cli', 'kimi']);
export const QUOTA_PROVIDER_TYPES = new Set<QuotaProviderType>([
'antigravity',
'claude',
'codex',
'gemini-cli',
'kimi'
]);
export const MIN_CARD_PAGE_SIZE = 3;
export const MAX_CARD_PAGE_SIZE = 30;
+16 -1
View File
@@ -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",
@@ -579,7 +588,13 @@
"seven_day_sonnet": "7-day Sonnet",
"seven_day_cowork": "7-day Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Extra Usage"
"extra_usage_label": "Extra Usage",
"plan_label": "Plan",
"plan_unknown": "Unknown",
"plan_free": "Free",
"plan_pro": "Pro",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
},
"codex_quota": {
"title": "Codex Quota",
+16 -1
View File
@@ -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": "Добавить провайдера",
@@ -582,7 +591,13 @@
"seven_day_sonnet": "7 дней Sonnet",
"seven_day_cowork": "7 дней Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "Дополнительное использование"
"extra_usage_label": "Дополнительное использование",
"plan_label": "План",
"plan_unknown": "Неизвестно",
"plan_free": "Free",
"plan_pro": "Pro",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
},
"codex_quota": {
"title": "Квота Codex",
+16 -1
View File
@@ -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 keyAmp官方)吗?",
"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 Keysk-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": "添加提供商",
@@ -579,7 +588,13 @@
"seven_day_sonnet": "7 天 Sonnet",
"seven_day_cowork": "7 天 Cowork",
"iguana_necktie": "Iguana Necktie",
"extra_usage_label": "额外用量"
"extra_usage_label": "额外用量",
"plan_label": "套餐",
"plan_unknown": "未知",
"plan_free": "免费版",
"plan_pro": "专业版",
"plan_max5": "Max 5x",
"plan_max20": "Max 20x"
},
"codex_quota": {
"title": "Codex 额度",
+136 -16
View File
@@ -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<AmpcodeFormState>(() => 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() {
</Button>
</div>
<div className="form-group">
<div className={layoutStyles.ampcodeUpstreamMappingsHeader}>
<label>{t('ai_providers.ampcode_upstream_api_keys_label')}</label>
<Button
variant="secondary"
size="sm"
onClick={() => {
setUpstreamApiKeysDirty(true);
setForm((prev) => ({
...prev,
upstreamApiKeyEntries: [
...prev.upstreamApiKeyEntries,
{ upstreamApiKey: '', clientApiKeysText: '' },
],
}));
}}
disabled={loading || saving || disableControls}
>
{t('ai_providers.ampcode_upstream_api_keys_add_btn')}
</Button>
</div>
<div className={layoutStyles.ampcodeUpstreamMappingsList}>
{(form.upstreamApiKeyEntries.length
? form.upstreamApiKeyEntries
: [{ upstreamApiKey: '', clientApiKeysText: '' }]
).map((entry, index, entries) => (
<div key={index} className={layoutStyles.ampcodeUpstreamMappingCard}>
<div className={layoutStyles.ampcodeUpstreamMappingCardTop}>
<span className={layoutStyles.ampcodeUpstreamMappingTitle}>
{t('ai_providers.ampcode_upstream_api_keys_item_title', { index: index + 1 })}
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setUpstreamApiKeysDirty(true);
setForm((prev) => {
const nextEntries = prev.upstreamApiKeyEntries.filter((_, entryIndex) => entryIndex !== index);
return {
...prev,
upstreamApiKeyEntries: nextEntries.length
? nextEntries
: [{ upstreamApiKey: '', clientApiKeysText: '' }],
};
});
}}
disabled={loading || saving || disableControls || entries.length <= 1}
>
{t('common.delete')}
</Button>
</div>
<input
className="input"
placeholder={t('ai_providers.ampcode_upstream_api_keys_upstream_placeholder')}
aria-label={t('ai_providers.ampcode_upstream_api_keys_upstream_placeholder')}
value={entry.upstreamApiKey}
onChange={(e) => {
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}
/>
<textarea
className="input"
placeholder={t('ai_providers.ampcode_upstream_api_keys_clients_placeholder')}
aria-label={t('ai_providers.ampcode_upstream_api_keys_clients_placeholder')}
value={entry.clientApiKeysText}
onChange={(e) => {
const value = e.target.value;
setUpstreamApiKeysDirty(true);
setForm((prev) => ({
...prev,
upstreamApiKeyEntries: prev.upstreamApiKeyEntries.map((item, itemIndex) =>
itemIndex === index ? { ...item, clientApiKeysText: value } : item
),
}));
}}
rows={3}
disabled={loading || saving || disableControls}
/>
</div>
))}
</div>
<div className="hint">{t('ai_providers.ampcode_upstream_api_keys_hint')}</div>
</div>
<div className="form-group">
<ToggleSwitch
label={t('ai_providers.ampcode_force_model_mappings_label')}
@@ -349,7 +469,7 @@ export function AiProvidersAmpcodeEditPage() {
<ModelInputList
entries={form.mappingEntries}
onChange={(entries) => {
setMappingsDirty(true);
setModelMappingsDirty(true);
setForm((prev) => ({ ...prev, mappingEntries: entries }));
}}
addLabel={t('ai_providers.ampcode_model_mappings_add_btn')}
@@ -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);
}
+18
View File
@@ -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() {
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
</div>
<div className="form-group">
<label>{t('ai_providers.excluded_models_label')}</label>
<textarea
className="input"
placeholder={t('ai_providers.excluded_models_placeholder')}
value={form.excludedText}
onChange={(e) => setForm((prev) => ({ ...prev, excludedText: e.target.value }))}
rows={4}
disabled={disableControls || saving}
/>
<div className="hint">{t('ai_providers.excluded_models_hint')}</div>
</div>
</>
)}
</Card>
+4
View File
@@ -314,6 +314,10 @@
background-image: linear-gradient(180deg, rgba(224, 247, 250, 0.12), rgba(224, 247, 250, 0));
}
.claudeCard {
background-image: linear-gradient(180deg, rgba(252, 228, 236, 0.18), rgba(252, 228, 236, 0));
}
.codexCard {
background-image: linear-gradient(180deg, rgba(255, 243, 224, 0.18), rgba(255, 243, 224, 0));
}
+25 -3
View File
@@ -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<AmpcodeConfig> {
@@ -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<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;
@@ -34,4 +57,3 @@ export const ampcodeApi = {
updateForceModelMappings: (enabled: boolean) => apiClient.put('/ampcode/force-model-mappings', { value: enabled })
};
+3
View File
@@ -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;
};
+38 -2
View File
@@ -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<string>();
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
};
+6 -1
View File
@@ -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;
}
+23
View File
@@ -132,6 +132,28 @@ export interface ClaudeUsagePayload {
extra_usage?: ClaudeExtraUsage | null;
}
export interface ClaudeProfileResponse {
account?: {
uuid?: string;
full_name?: string;
display_name?: string;
email?: string;
has_claude_max?: boolean;
has_claude_pro?: boolean;
created_at?: string;
};
organization?: {
uuid?: string;
name?: string;
organization_type?: string;
billing_type?: string;
rate_limit_tier?: string;
has_extra_usage_enabled?: boolean;
subscription_status?: string;
subscription_created_at?: string;
};
}
export interface ClaudeQuotaWindow {
id: string;
label: string;
@@ -144,6 +166,7 @@ export interface ClaudeQuotaState {
status: 'idle' | 'loading' | 'success' | 'error';
windows: ClaudeQuotaWindow[];
extraUsage?: ClaudeExtraUsage | null;
planType?: string | null;
error?: string;
errorStatus?: number;
}
+2
View File
@@ -156,6 +156,8 @@ export const GEMINI_CLI_GROUP_LOOKUP = new Map(
export const GEMINI_CLI_IGNORED_MODEL_PREFIXES = ['gemini-2.0-flash'];
// Claude API configuration
export const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile';
export const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
export const CLAUDE_REQUEST_HEADERS = {