Skip to content

Commit a176922

Browse files
committed
feat: add Codex multi-key editor
1 parent e8d697d commit a176922

16 files changed

Lines changed: 416 additions & 76 deletions

src/components/providers/CodexSection/CodexSection.tsx

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Fragment, useMemo } from 'react';
22
import { useTranslation } from 'react-i18next';
33
import { Button } from '@/components/ui/Button';
44
import { Card } from '@/components/ui/Card';
5+
import { IconCheck, IconX } from '@/components/ui/icons';
56
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
67
import iconCodex from '@/assets/icons/codex.svg';
78
import type { ProviderKeyConfig } from '@/types';
@@ -18,7 +19,13 @@ import {
1819
import styles from '@/pages/AiProvidersPage.module.scss';
1920
import { ProviderList } from '../ProviderList';
2021
import { ProviderStatusBar } from '../ProviderStatusBar';
21-
import { getStatsBySource, hasDisableAllModelsRule } from '../utils';
22+
import {
23+
getApiKeyEntriesStats,
24+
getProviderApiKeyEntries,
25+
getProviderPrimaryApiKey,
26+
getStatsBySource,
27+
hasDisableAllModelsRule,
28+
} from '../utils';
2229

2330
interface CodexSectionProps {
2431
configs: ProviderKeyConfig[];
@@ -52,15 +59,15 @@ export function CodexSection({
5259
const statusBarCache = useMemo(() => {
5360
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
5461

55-
configs.forEach((config) => {
56-
if (!config.apiKey) return;
57-
const candidates = buildCandidateUsageSourceIds({
58-
apiKey: config.apiKey,
59-
prefix: config.prefix,
62+
configs.forEach((config, index) => {
63+
const candidates = new Set<string>();
64+
buildCandidateUsageSourceIds({ prefix: config.prefix }).forEach((id) => candidates.add(id));
65+
getProviderApiKeyEntries(config).forEach((entry) => {
66+
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
6067
});
61-
if (!candidates.length) return;
68+
if (!candidates.size) return;
6269
cache.set(
63-
config.apiKey,
70+
`${getProviderPrimaryApiKey(config)}:${index}`,
6471
calculateStatusBarData(collectUsageDetailsForCandidates(usageDetailsBySource, candidates))
6572
);
6673
});
@@ -86,7 +93,7 @@ export function CodexSection({
8693
<ProviderList<ProviderKeyConfig>
8794
items={configs}
8895
loading={loading}
89-
keyField={(item) => item.apiKey}
96+
keyField={(item, index) => `${getProviderPrimaryApiKey(item) || 'codex'}:${index}`}
9097
emptyTitle={t('ai_providers.codex_empty_title')}
9198
emptyDescription={t('ai_providers.codex_empty_desc')}
9299
onEdit={onEdit}
@@ -101,19 +108,25 @@ export function CodexSection({
101108
onChange={(value) => void onToggle(index, value)}
102109
/>
103110
)}
104-
renderContent={(item) => {
105-
const stats = getStatsBySource(item.apiKey, keyStats, item.prefix);
111+
renderContent={(item, index) => {
112+
const apiKeyEntries = getProviderApiKeyEntries(item);
113+
const stats = getApiKeyEntriesStats(apiKeyEntries, keyStats, item.prefix);
106114
const headerEntries = Object.entries(item.headers || {});
107115
const configDisabled = hasDisableAllModelsRule(item.excludedModels);
108116
const excludedModels = item.excludedModels ?? [];
109-
const statusData = statusBarCache.get(item.apiKey) || calculateStatusBarData([]);
117+
const statusData =
118+
statusBarCache.get(`${getProviderPrimaryApiKey(item) || 'codex'}:${index}`) ||
119+
calculateStatusBarData([]);
110120

111121
return (
112122
<Fragment>
113-
<div className="item-title">{t('ai_providers.codex_item_title')}</div>
114-
<div className={styles.fieldRow}>
115-
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
116-
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
123+
<div className="item-title">
124+
{t('ai_providers.codex_item_title')}
125+
{configDisabled && (
126+
<span className="status-badge warning">
127+
{t('ai_providers.config_disabled_badge')}
128+
</span>
129+
)}
117130
</div>
118131
{item.priority !== undefined && (
119132
<div className={styles.fieldRow}>
@@ -133,18 +146,45 @@ export function CodexSection({
133146
<span className={styles.fieldValue}>{item.baseUrl}</span>
134147
</div>
135148
)}
136-
{item.proxyUrl && (
137-
<div className={styles.fieldRow}>
138-
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
139-
<span className={styles.fieldValue}>{item.proxyUrl}</span>
140-
</div>
141-
)}
142149
{item.websockets !== undefined && (
143150
<div className={styles.fieldRow}>
144151
<span className={styles.fieldLabel}>{t('ai_providers.codex_websockets_label')}:</span>
145152
<span className={styles.fieldValue}>{item.websockets ? t('common.yes') : t('common.no')}</span>
146153
</div>
147154
)}
155+
{apiKeyEntries.length > 0 && (
156+
<div className={styles.apiKeyEntriesSection}>
157+
<div className={styles.apiKeyEntriesLabel}>
158+
{t('ai_providers.codex_keys_count')}: {apiKeyEntries.length}
159+
</div>
160+
<div className={styles.apiKeyEntryList}>
161+
{apiKeyEntries.map((entry, entryIndex) => {
162+
const entryStats = getStatsBySource(entry.apiKey, keyStats);
163+
return (
164+
<div key={entryIndex} className={styles.apiKeyEntryCard}>
165+
<span className={styles.apiKeyEntryIndex}>{entryIndex + 1}</span>
166+
<span className={styles.apiKeyEntryKey}>{maskApiKey(entry.apiKey)}</span>
167+
{entry.proxyUrl && (
168+
<span className={styles.apiKeyEntryProxy}>{entry.proxyUrl}</span>
169+
)}
170+
<div className={styles.apiKeyEntryStats}>
171+
<span
172+
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatSuccess}`}
173+
>
174+
<IconCheck size={12} /> {entryStats.success}
175+
</span>
176+
<span
177+
className={`${styles.apiKeyEntryStat} ${styles.apiKeyEntryStatFailure}`}
178+
>
179+
<IconX size={12} /> {entryStats.failure}
180+
</span>
181+
</div>
182+
</div>
183+
);
184+
})}
185+
</div>
186+
</div>
187+
)}
148188
{headerEntries.length > 0 && (
149189
<div className={styles.headerBadgeList}>
150190
{headerEntries.map(([key, value]) => (
@@ -154,11 +194,6 @@ export function CodexSection({
154194
))}
155195
</div>
156196
)}
157-
{configDisabled && (
158-
<div className="status-badge warning" style={{ marginTop: 8, marginBottom: 0 }}>
159-
{t('ai_providers.config_disabled_badge')}
160-
</div>
161-
)}
162197
{item.models?.length ? (
163198
<div className={styles.modelTagList}>
164199
<span className={styles.modelCountLabel}>

src/components/providers/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
4242
headers: HeaderEntry[];
4343
modelEntries: ModelEntry[];
4444
excludedText: string;
45+
apiKeyEntries: ApiKeyEntry[];
4546
};
4647

4748
export type VertexFormState = Omit<ProviderKeyConfig, 'headers'> & {

src/components/providers/utils.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import type { AmpcodeConfig, AmpcodeModelMapping, AmpcodeUpstreamApiKeyMapping, ApiKeyEntry } from '@/types';
1+
import type {
2+
AmpcodeConfig,
3+
AmpcodeModelMapping,
4+
AmpcodeUpstreamApiKeyMapping,
5+
ApiKeyEntry,
6+
ProviderKeyConfig,
7+
} from '@/types';
28
import { buildCandidateUsageSourceIds, type KeyStatBucket, type KeyStats } from '@/utils/usage';
39
import type { AmpcodeFormState, AmpcodeUpstreamApiKeyEntry, ModelEntry } from './types';
410

@@ -109,8 +115,8 @@ export const getStatsBySource = (
109115
return { success, failure };
110116
};
111117

112-
// 对于 OpenAI 提供商,汇总所有 apiKeyEntries 的统计 - 与旧版逻辑一致
113-
export const getOpenAIProviderStats = (
118+
// 对于多 key 提供商,汇总所有 apiKeyEntries 的统计 - 逻辑与 OpenAI 一致,Codex/其他 provider 也可以复用
119+
export const getApiKeyEntriesStats = (
114120
apiKeyEntries: ApiKeyEntry[] | undefined,
115121
keyStats: KeyStats,
116122
providerPrefix?: string
@@ -135,6 +141,32 @@ export const getOpenAIProviderStats = (
135141
return { success, failure };
136142
};
137143

144+
export const getOpenAIProviderStats = getApiKeyEntriesStats;
145+
146+
export const getProviderApiKeyEntries = (
147+
config?: Pick<ProviderKeyConfig, 'apiKey' | 'apiKeyEntries' | 'proxyUrl'>
148+
): ApiKeyEntry[] => {
149+
const sharedProxyUrl = String(config?.proxyUrl ?? '').trim();
150+
if (config?.apiKeyEntries?.length) {
151+
return config.apiKeyEntries.map((entry) => ({
152+
...entry,
153+
proxyUrl: String(entry?.proxyUrl ?? '').trim() || sharedProxyUrl,
154+
}));
155+
}
156+
const apiKey = String(config?.apiKey ?? '').trim();
157+
if (!apiKey) {
158+
return [];
159+
}
160+
return [buildApiKeyEntry({ apiKey, proxyUrl: String(config?.proxyUrl ?? '').trim() })];
161+
};
162+
163+
export const getProviderPrimaryApiKey = (
164+
config?: Pick<ProviderKeyConfig, 'apiKey' | 'apiKeyEntries' | 'proxyUrl'>
165+
): string => {
166+
const entries = getProviderApiKeyEntries(config);
167+
return entries[0]?.apiKey ?? String(config?.apiKey ?? '').trim();
168+
};
169+
138170
export const buildApiKeyEntry = (input?: Partial<ApiKeyEntry>): ApiKeyEntry => ({
139171
apiKey: input?.apiKey ?? '',
140172
proxyUrl: input?.proxyUrl ?? '',

src/components/usage/CredentialStatsCard.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@ interface CredentialBucket {
3939
failure: number;
4040
}
4141

42+
const providerApiKeyEntries = (config: ProviderKeyConfig) => {
43+
if (config.apiKeyEntries?.length) {
44+
return config.apiKeyEntries;
45+
}
46+
return config.apiKey ? [{ apiKey: config.apiKey }] : [];
47+
};
48+
4249
export function CredentialStatsCard({
4350
usage,
4451
loading,
@@ -134,24 +141,22 @@ export function CredentialStatsCard({
134141
};
135142

136143
// Aggregate all candidate source IDs for one provider config into a single row
137-
const addConfigRow = (
138-
apiKey: string,
139-
prefix: string | undefined,
144+
const addCandidateRow = (
145+
candidates: Iterable<string>,
140146
name: string,
141147
type: string,
142148
rowKey: string,
143149
) => {
144-
const candidates = buildCandidateUsageSourceIds({ apiKey, prefix });
145150
let success = 0;
146151
let failure = 0;
147-
candidates.forEach((id) => {
152+
for (const id of candidates) {
148153
const bucket = bySource[id];
149154
if (bucket) {
150155
success += bucket.success;
151156
failure += bucket.failure;
152157
consumedSourceIds.add(id);
153158
}
154-
});
159+
}
155160
const total = success + failure;
156161
if (total > 0) {
157162
result.push({
@@ -166,13 +171,32 @@ export function CredentialStatsCard({
166171
}
167172
};
168173

174+
const addConfigRow = (
175+
apiKey: string,
176+
prefix: string | undefined,
177+
name: string,
178+
type: string,
179+
rowKey: string,
180+
) => {
181+
addCandidateRow(buildCandidateUsageSourceIds({ apiKey, prefix }), name, type, rowKey);
182+
};
183+
184+
const buildProviderEntryCandidates = (config: ProviderKeyConfig) => {
185+
const candidates = new Set<string>();
186+
buildCandidateUsageSourceIds({ prefix: config.prefix }).forEach((id) => candidates.add(id));
187+
providerApiKeyEntries(config).forEach((entry) => {
188+
buildCandidateUsageSourceIds({ apiKey: entry.apiKey }).forEach((id) => candidates.add(id));
189+
});
190+
return candidates;
191+
};
192+
169193
// Provider rows — one row per config, stats merged across all its candidate source IDs
170194
geminiKeys.forEach((c, i) =>
171195
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Gemini #${i + 1}`, 'gemini', `gemini:${i}`));
172196
claudeConfigs.forEach((c, i) =>
173197
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Claude #${i + 1}`, 'claude', `claude:${i}`));
174198
codexConfigs.forEach((c, i) =>
175-
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
199+
addCandidateRow(buildProviderEntryCandidates(c), c.prefix?.trim() || `Codex #${i + 1}`, 'codex', `codex:${i}`));
176200
vertexConfigs.forEach((c, i) =>
177201
addConfigRow(c.apiKey, c.prefix, c.prefix?.trim() || `Vertex #${i + 1}`, 'vertex', `vertex:${i}`));
178202
// OpenAI compatibility providers — one row per provider, merged across all apiKey entries (prefix counted once).

src/i18n/locales/en.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,15 @@
260260
"codex_item_title": "Codex Configuration",
261261
"codex_add_modal_title": "Add Codex API Configuration",
262262
"codex_add_modal_key_label": "API Key:",
263+
"codex_add_modal_keys_label": "API Keys",
263264
"codex_add_modal_key_placeholder": "Please enter Codex API key",
264265
"codex_add_modal_url_label": "Base URL (Required):",
265266
"codex_add_modal_url_placeholder": "e.g.: https://api.example.com",
266267
"codex_add_modal_proxy_label": "Proxy URL (Optional):",
267268
"codex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
269+
"codex_keys_hint": "Add multiple Codex API keys under the same Base URL. Each key can use its own optional proxy URL.",
270+
"codex_keys_add_btn": "Add Key",
271+
"codex_keys_count": "Keys Count",
268272
"codex_websockets_label": "Websockets",
269273
"codex_websockets_hint": "Enable Responses API websocket transport for this key.",
270274
"codex_models_label": "Custom Models (Optional):",
@@ -1470,6 +1474,7 @@
14701474
"codex_config_updated": "Codex configuration updated successfully",
14711475
"codex_config_deleted": "Codex configuration deleted successfully",
14721476
"codex_base_url_required": "Please enter the Codex Base URL",
1477+
"codex_key_required": "Please add at least one Codex API key",
14731478
"claude_config_added": "Claude configuration added successfully",
14741479
"claude_config_updated": "Claude configuration updated successfully",
14751480
"claude_config_deleted": "Claude configuration deleted successfully",

src/i18n/locales/ru.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,15 @@
260260
"codex_item_title": "Конфигурация Codex",
261261
"codex_add_modal_title": "Добавление конфигурации Codex API",
262262
"codex_add_modal_key_label": "API-ключ:",
263+
"codex_add_modal_keys_label": "API-ключи",
263264
"codex_add_modal_key_placeholder": "Введите API-ключ Codex",
264265
"codex_add_modal_url_label": "Базовый URL (обязательно):",
265266
"codex_add_modal_url_placeholder": "например: https://api.example.com",
266267
"codex_add_modal_proxy_label": "URL прокси (необязательно):",
267268
"codex_add_modal_proxy_placeholder": "например: socks5://proxy.example.com:1080",
269+
"codex_keys_hint": "Добавляйте несколько ключей Codex в рамках одного Base URL. Для каждого ключа можно указать свой прокси URL.",
270+
"codex_keys_add_btn": "Добавить ключ",
271+
"codex_keys_count": "Количество ключей",
268272
"codex_websockets_label": "Websockets",
269273
"codex_websockets_hint": "Включает websocket-транспорт Responses API для этого ключа.",
270274
"codex_models_label": "Пользовательские модели (необязательно):",
@@ -1469,6 +1473,7 @@
14691473
"codex_config_updated": "Конфигурация Codex успешно обновлена",
14701474
"codex_config_deleted": "Конфигурация Codex успешно удалена",
14711475
"codex_base_url_required": "Введите базовый URL Codex",
1476+
"codex_key_required": "Добавьте хотя бы один API-ключ Codex",
14721477
"claude_config_added": "Конфигурация Claude успешно добавлена",
14731478
"claude_config_updated": "Конфигурация Claude успешно обновлена",
14741479
"claude_config_deleted": "Конфигурация Claude успешно удалена",

src/i18n/locales/zh-CN.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,15 @@
260260
"codex_item_title": "Codex配置",
261261
"codex_add_modal_title": "添加Codex API配置",
262262
"codex_add_modal_key_label": "API密钥:",
263+
"codex_add_modal_keys_label": "API密钥",
263264
"codex_add_modal_key_placeholder": "请输入Codex API密钥",
264265
"codex_add_modal_url_label": "Base URL (必填):",
265266
"codex_add_modal_url_placeholder": "例如: https://api.example.com",
266267
"codex_add_modal_proxy_label": "代理 URL (可选):",
267268
"codex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
269+
"codex_keys_hint": "同一个 Base URL 下可添加多个 Codex API Key,每个密钥可单独设置代理。",
270+
"codex_keys_add_btn": "添加密钥",
271+
"codex_keys_count": "密钥数量",
268272
"codex_websockets_label": "Websockets",
269273
"codex_websockets_hint": "开启 Responses API 的 websocket 传输。",
270274
"codex_models_label": "自定义模型 (可选):",
@@ -1470,6 +1474,7 @@
14701474
"codex_config_updated": "Codex配置更新成功",
14711475
"codex_config_deleted": "Codex配置删除成功",
14721476
"codex_base_url_required": "请填写Codex Base URL",
1477+
"codex_key_required": "请至少填写一个 Codex API 密钥",
14731478
"claude_config_added": "Claude配置添加成功",
14741479
"claude_config_updated": "Claude配置更新成功",
14751480
"claude_config_deleted": "Claude配置删除成功",

src/pages/AiProvidersClaudeEditLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const buildEmptyForm = (): ProviderFormState => ({
4949
headers: [],
5050
models: [],
5151
excludedModels: [],
52+
apiKeyEntries: [],
5253
modelEntries: [{ name: '', alias: '' }],
5354
excludedText: '',
5455
});
@@ -250,6 +251,7 @@ export function AiProvidersClaudeEditLayout() {
250251
headers: headersToEntries(initialData.headers),
251252
modelEntries: modelsToEntries(initialData.models),
252253
excludedText: excludedModelsToText(initialData.excludedModels),
254+
apiKeyEntries: initialData.apiKeyEntries ?? [],
253255
};
254256
const available = seededForm.modelEntries.map((entry) => entry.name.trim()).filter(Boolean);
255257
const baseline = buildClaudeBaseline(seededForm);

0 commit comments

Comments
 (0)