Compare commits

...

10 Commits

Author SHA1 Message Date
Supra4E8C
73ee6eb2f3 fix(ai-providers): keep custom header editing stable in modals 2026-01-10 14:00:50 +08:00
Supra4E8C
161d5d1e7f Merge pull request #49 from sunday-ma/feature/fix-login-enter-key
fix: 添加登录表单 Enter 键提交功能
2026-01-08 19:16:48 +08:00
Sunny
3cbd04b296 Update src/pages/LoginPage.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-08 14:27:33 +08:00
Sunny
859f7f120c Update src/pages/LoginPage.tsx
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-08 14:27:18 +08:00
sunday-ma
fea29f7318 fix: 添加登录表单 Enter 键提交功能 2026-01-08 14:16:38 +08:00
Supra4E8C
f663b83ac8 feat(auth-files): normalize OAuth excluded models handling and update related API methods 2026-01-07 12:26:33 +08:00
LTbinglingfeng
ee99836285 Revert "feat(auth-files): add external migration modal for antigravity credentials"
This reverts commit 2086c348a9.
2026-01-07 00:02:45 +08:00
Supra4E8C
2086c348a9 feat(auth-files): add external migration modal for antigravity credentials 2026-01-06 18:21:34 +08:00
LTbinglingfeng
a8abf71bfe fix(settings): align log size and routing update controls 2026-01-06 00:30:06 +08:00
Supra4E8C
8dca670358 feat: add vertex provider, oauth model mappings, and routing/log settings 2026-01-05 19:03:05 +08:00
24 changed files with 1477 additions and 178 deletions

14
package-lock.json generated
View File

@@ -3780,9 +3780,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.10.1", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.12.0.tgz",
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==", "integrity": "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -3802,12 +3802,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.10.1", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.12.0.tgz",
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==", "integrity": "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.10.1" "react-router": "7.12.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList'; import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils'; import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types'; import type { ProviderFormState, ProviderModalProps } from '../types';
@@ -19,7 +19,7 @@ const buildEmptyForm = (): ProviderFormState => ({
prefix: '', prefix: '',
baseUrl: '', baseUrl: '',
proxyUrl: '', proxyUrl: '',
headers: {}, headers: [],
models: [], models: [],
excludedModels: [], excludedModels: [],
modelEntries: [{ name: '', alias: '' }], modelEntries: [{ name: '', alias: '' }],
@@ -43,7 +43,7 @@ export function ClaudeModal({
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setForm({ setForm({
...initialData, ...initialData,
headers: initialData.headers ?? {}, headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models), modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels), excludedText: excludedModelsToText(initialData.excludedModels),
}); });
@@ -95,8 +95,8 @@ export function ClaudeModal({
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/> />
<HeaderInputList <HeaderInputList
entries={headersToEntries(form.headers)} entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))} onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}

View File

@@ -5,7 +5,7 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import type { ProviderKeyConfig } from '@/types'; import type { ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { headersToEntries } from '@/utils/headers';
import { modelsToEntries } from '@/components/ui/ModelInputList'; import { modelsToEntries } from '@/components/ui/ModelInputList';
import { excludedModelsToText } from '../utils'; import { excludedModelsToText } from '../utils';
import type { ProviderFormState, ProviderModalProps } from '../types'; import type { ProviderFormState, ProviderModalProps } from '../types';
@@ -19,7 +19,7 @@ const buildEmptyForm = (): ProviderFormState => ({
prefix: '', prefix: '',
baseUrl: '', baseUrl: '',
proxyUrl: '', proxyUrl: '',
headers: {}, headers: [],
models: [], models: [],
excludedModels: [], excludedModels: [],
modelEntries: [{ name: '', alias: '' }], modelEntries: [{ name: '', alias: '' }],
@@ -43,7 +43,7 @@ export function CodexModal({
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setForm({ setForm({
...initialData, ...initialData,
headers: initialData.headers ?? {}, headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models), modelEntries: modelsToEntries(initialData.models),
excludedText: excludedModelsToText(initialData.excludedModels), excludedText: excludedModelsToText(initialData.excludedModels),
}); });
@@ -95,8 +95,8 @@ export function CodexModal({
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/> />
<HeaderInputList <HeaderInputList
entries={headersToEntries(form.headers)} entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))} onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}

View File

@@ -5,7 +5,7 @@ import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import type { GeminiKeyConfig } from '@/types'; import type { GeminiKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { headersToEntries } from '@/utils/headers';
import { excludedModelsToText } from '../utils'; import { excludedModelsToText } from '../utils';
import type { GeminiFormState, ProviderModalProps } from '../types'; import type { GeminiFormState, ProviderModalProps } from '../types';
@@ -17,7 +17,7 @@ const buildEmptyForm = (): GeminiFormState => ({
apiKey: '', apiKey: '',
prefix: '', prefix: '',
baseUrl: '', baseUrl: '',
headers: {}, headers: [],
excludedModels: [], excludedModels: [],
excludedText: '', excludedText: '',
}); });
@@ -39,7 +39,7 @@ export function GeminiModal({
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setForm({ setForm({
...initialData, ...initialData,
headers: initialData.headers ?? {}, headers: headersToEntries(initialData.headers),
excludedText: excludedModelsToText(initialData.excludedModels), excludedText: excludedModelsToText(initialData.excludedModels),
}); });
return; return;
@@ -91,8 +91,8 @@ export function GeminiModal({
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/> />
<HeaderInputList <HeaderInputList
entries={headersToEntries(form.headers)} entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))} onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')} addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')} keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')} valuePlaceholder={t('common.custom_headers_value_placeholder')}

View File

@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { HeaderInputList } from '@/components/ui/HeaderInputList';
import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal';
import { ModelInputList, modelsToEntries } from '@/components/ui/ModelInputList';
import type { ProviderKeyConfig } from '@/types';
import { headersToEntries } from '@/utils/headers';
import type { ProviderModalProps, VertexFormState } from '../types';
interface VertexModalProps extends ProviderModalProps<ProviderKeyConfig, VertexFormState> {
isSaving: boolean;
}
const buildEmptyForm = (): VertexFormState => ({
apiKey: '',
prefix: '',
baseUrl: '',
proxyUrl: '',
headers: [],
models: [],
modelEntries: [{ name: '', alias: '' }],
});
export function VertexModal({
isOpen,
editIndex,
initialData,
onClose,
onSave,
isSaving,
}: VertexModalProps) {
const { t } = useTranslation();
const [form, setForm] = useState<VertexFormState>(buildEmptyForm);
useEffect(() => {
if (!isOpen) return;
if (initialData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setForm({
...initialData,
headers: headersToEntries(initialData.headers),
modelEntries: modelsToEntries(initialData.models),
});
return;
}
setForm(buildEmptyForm());
}, [initialData, isOpen]);
return (
<Modal
open={isOpen}
onClose={onClose}
title={
editIndex !== null
? t('ai_providers.vertex_edit_modal_title')
: t('ai_providers.vertex_add_modal_title')
}
footer={
<>
<Button variant="secondary" onClick={onClose} disabled={isSaving}>
{t('common.cancel')}
</Button>
<Button onClick={() => void onSave(form, editIndex)} loading={isSaving}>
{t('common.save')}
</Button>
</>
}
>
<Input
label={t('ai_providers.vertex_add_modal_key_label')}
placeholder={t('ai_providers.vertex_add_modal_key_placeholder')}
value={form.apiKey}
onChange={(e) => setForm((prev) => ({ ...prev, apiKey: e.target.value }))}
/>
<Input
label={t('ai_providers.prefix_label')}
placeholder={t('ai_providers.prefix_placeholder')}
value={form.prefix ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, prefix: e.target.value }))}
hint={t('ai_providers.prefix_hint')}
/>
<Input
label={t('ai_providers.vertex_add_modal_url_label')}
placeholder={t('ai_providers.vertex_add_modal_url_placeholder')}
value={form.baseUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, baseUrl: e.target.value }))}
/>
<Input
label={t('ai_providers.vertex_add_modal_proxy_label')}
placeholder={t('ai_providers.vertex_add_modal_proxy_placeholder')}
value={form.proxyUrl ?? ''}
onChange={(e) => setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))}
/>
<HeaderInputList
entries={form.headers}
onChange={(entries) => setForm((prev) => ({ ...prev, headers: entries }))}
addLabel={t('common.custom_headers_add')}
keyPlaceholder={t('common.custom_headers_key_placeholder')}
valuePlaceholder={t('common.custom_headers_value_placeholder')}
/>
<div className="form-group">
<label>{t('ai_providers.vertex_models_label')}</label>
<ModelInputList
entries={form.modelEntries}
onChange={(entries) => setForm((prev) => ({ ...prev, modelEntries: entries }))}
addLabel={t('ai_providers.vertex_models_add_btn')}
namePlaceholder={t('common.model_name_placeholder')}
aliasPlaceholder={t('common.model_alias_placeholder')}
disabled={isSaving}
/>
<div className="hint">{t('ai_providers.vertex_models_hint')}</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,170 @@
import { Fragment, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import iconVertex from '@/assets/icons/vertex.svg';
import type { ProviderKeyConfig } from '@/types';
import { maskApiKey } from '@/utils/format';
import { calculateStatusBarData, type KeyStats, type UsageDetail } from '@/utils/usage';
import styles from '@/pages/AiProvidersPage.module.scss';
import { ProviderList } from '../ProviderList';
import { ProviderStatusBar } from '../ProviderStatusBar';
import { getStatsBySource } from '../utils';
import type { VertexFormState } from '../types';
import { VertexModal } from './VertexModal';
interface VertexSectionProps {
configs: ProviderKeyConfig[];
keyStats: KeyStats;
usageDetails: UsageDetail[];
loading: boolean;
disableControls: boolean;
isSaving: boolean;
isSwitching: boolean;
isModalOpen: boolean;
modalIndex: number | null;
onAdd: () => void;
onEdit: (index: number) => void;
onDelete: (index: number) => void;
onCloseModal: () => void;
onSave: (data: VertexFormState, index: number | null) => Promise<void>;
}
export function VertexSection({
configs,
keyStats,
usageDetails,
loading,
disableControls,
isSaving,
isSwitching,
isModalOpen,
modalIndex,
onAdd,
onEdit,
onDelete,
onCloseModal,
onSave,
}: VertexSectionProps) {
const { t } = useTranslation();
const actionsDisabled = disableControls || isSaving || isSwitching;
const statusBarCache = useMemo(() => {
const cache = new Map<string, ReturnType<typeof calculateStatusBarData>>();
const allApiKeys = new Set<string>();
configs.forEach((config) => config.apiKey && allApiKeys.add(config.apiKey));
allApiKeys.forEach((apiKey) => {
cache.set(apiKey, calculateStatusBarData(usageDetails, apiKey));
});
return cache;
}, [configs, usageDetails]);
const initialData = modalIndex !== null ? configs[modalIndex] : undefined;
return (
<>
<Card
title={
<span className={styles.cardTitle}>
<img src={iconVertex} alt="" className={styles.cardTitleIcon} />
{t('ai_providers.vertex_title')}
</span>
}
extra={
<Button size="sm" onClick={onAdd} disabled={actionsDisabled}>
{t('ai_providers.vertex_add_button')}
</Button>
}
>
<ProviderList<ProviderKeyConfig>
items={configs}
loading={loading}
keyField={(item) => item.apiKey}
emptyTitle={t('ai_providers.vertex_empty_title')}
emptyDescription={t('ai_providers.vertex_empty_desc')}
onEdit={onEdit}
onDelete={onDelete}
actionsDisabled={actionsDisabled}
renderContent={(item, index) => {
const stats = getStatsBySource(item.apiKey, keyStats, maskApiKey);
const headerEntries = Object.entries(item.headers || {});
const statusData =
statusBarCache.get(item.apiKey) || calculateStatusBarData([], item.apiKey);
return (
<Fragment>
<div className="item-title">
{t('ai_providers.vertex_item_title')} #{index + 1}
</div>
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.api_key')}:</span>
<span className={styles.fieldValue}>{maskApiKey(item.apiKey)}</span>
</div>
{item.prefix && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.prefix')}:</span>
<span className={styles.fieldValue}>{item.prefix}</span>
</div>
)}
{item.baseUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.base_url')}:</span>
<span className={styles.fieldValue}>{item.baseUrl}</span>
</div>
)}
{item.proxyUrl && (
<div className={styles.fieldRow}>
<span className={styles.fieldLabel}>{t('common.proxy_url')}:</span>
<span className={styles.fieldValue}>{item.proxyUrl}</span>
</div>
)}
{headerEntries.length > 0 && (
<div className={styles.headerBadgeList}>
{headerEntries.map(([key, value]) => (
<span key={key} className={styles.headerBadge}>
<strong>{key}:</strong> {value}
</span>
))}
</div>
)}
{item.models?.length ? (
<div className={styles.modelTagList}>
<span className={styles.modelCountLabel}>
{t('ai_providers.vertex_models_count')}: {item.models.length}
</span>
{item.models.map((model) => (
<span key={`${model.name}-${model.alias || 'default'}`} className={styles.modelTag}>
<span className={styles.modelName}>{model.name}</span>
{model.alias && (
<span className={styles.modelAlias}>{model.alias}</span>
)}
</span>
))}
</div>
) : null}
<div className={styles.cardStats}>
<span className={`${styles.statPill} ${styles.statSuccess}`}>
{t('stats.success')}: {stats.success}
</span>
<span className={`${styles.statPill} ${styles.statFailure}`}>
{t('stats.failure')}: {stats.failure}
</span>
</div>
<ProviderStatusBar statusData={statusData} />
</Fragment>
);
}}
/>
</Card>
<VertexModal
isOpen={isModalOpen}
editIndex={modalIndex}
initialData={initialData}
onClose={onCloseModal}
onSave={onSave}
isSaving={isSaving}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { VertexSection } from './VertexSection';

View File

@@ -3,6 +3,7 @@ export { ClaudeSection } from './ClaudeSection';
export { CodexSection } from './CodexSection'; export { CodexSection } from './CodexSection';
export { GeminiSection } from './GeminiSection'; export { GeminiSection } from './GeminiSection';
export { OpenAISection } from './OpenAISection'; export { OpenAISection } from './OpenAISection';
export { VertexSection } from './VertexSection';
export { ProviderList } from './ProviderList'; export { ProviderList } from './ProviderList';
export { ProviderStatusBar } from './ProviderStatusBar'; export { ProviderStatusBar } from './ProviderStatusBar';
export * from './hooks/useProviderStats'; export * from './hooks/useProviderStats';

View File

@@ -6,6 +6,7 @@ export type ProviderModal =
| { type: 'gemini'; index: number | null } | { type: 'gemini'; index: number | null }
| { type: 'codex'; index: number | null } | { type: 'codex'; index: number | null }
| { type: 'claude'; index: number | null } | { type: 'claude'; index: number | null }
| { type: 'vertex'; index: number | null }
| { type: 'ampcode'; index: null } | { type: 'ampcode'; index: null }
| { type: 'openai'; index: number | null }; | { type: 'openai'; index: number | null };
@@ -31,13 +32,22 @@ export interface AmpcodeFormState {
mappingEntries: ModelEntry[]; mappingEntries: ModelEntry[];
} }
export type GeminiFormState = GeminiKeyConfig & { excludedText: string }; export type GeminiFormState = Omit<GeminiKeyConfig, 'headers'> & {
headers: HeaderEntry[];
excludedText: string;
};
export type ProviderFormState = ProviderKeyConfig & { export type ProviderFormState = Omit<ProviderKeyConfig, 'headers'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[]; modelEntries: ModelEntry[];
excludedText: string; excludedText: string;
}; };
export type VertexFormState = Omit<ProviderKeyConfig, 'headers' | 'excludedModels'> & {
headers: HeaderEntry[];
modelEntries: ModelEntry[];
};
export interface ProviderSectionProps<TConfig> { export interface ProviderSectionProps<TConfig> {
configs: TConfig[]; configs: TConfig[];
keyStats: KeyStats; keyStats: KeyStats;

View File

@@ -137,11 +137,22 @@
"usage_statistics_enable": "Enable usage statistics", "usage_statistics_enable": "Enable usage statistics",
"logging_title": "Logging", "logging_title": "Logging",
"logging_to_file_enable": "Enable logging to file", "logging_to_file_enable": "Enable logging to file",
"logs_max_total_size_title": "Log Size Limit",
"logs_max_total_size_label": "Total log size cap (MB):",
"logs_max_total_size_hint": "Set to 0 to disable the limit.",
"logs_max_total_size_update": "Update",
"request_log_title": "Request Logging", "request_log_title": "Request Logging",
"request_log_enable": "Enable request logging", "request_log_enable": "Enable request logging",
"request_log_warning": "Keep this off unless you need detailed troubleshooting.", "request_log_warning": "Keep this off unless you need detailed troubleshooting.",
"force_model_prefix_enable": "Force model prefix",
"ws_auth_title": "WebSocket Authentication", "ws_auth_title": "WebSocket Authentication",
"ws_auth_enable": "Require auth for /ws/*" "ws_auth_enable": "Require auth for /ws/*",
"routing_title": "Routing Strategy",
"routing_strategy_label": "Routing strategy:",
"routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key.",
"routing_strategy_update": "Update",
"routing_strategy_round_robin": "round-robin (cycle)",
"routing_strategy_fill_first": "fill-first (prioritize)"
}, },
"api_keys": { "api_keys": {
"title": "API Keys Management", "title": "API Keys Management",
@@ -221,6 +232,27 @@
"claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.", "claude_models_hint": "Leave empty to allow all models, or add name[, alias] entries to limit/alias them.",
"claude_models_add_btn": "Add Model", "claude_models_add_btn": "Add Model",
"claude_models_count": "Models Count", "claude_models_count": "Models Count",
"vertex_title": "Vertex API Configuration",
"vertex_add_button": "Add Configuration",
"vertex_empty_title": "No Vertex Configuration",
"vertex_empty_desc": "Click the button above to add the first configuration",
"vertex_item_title": "Vertex Configuration",
"vertex_add_modal_title": "Add Vertex API Configuration",
"vertex_add_modal_key_label": "API Key:",
"vertex_add_modal_key_placeholder": "Please enter Vertex API key",
"vertex_add_modal_url_label": "Base URL (Required):",
"vertex_add_modal_url_placeholder": "e.g.: https://example.com/api",
"vertex_add_modal_proxy_label": "Proxy URL (Optional):",
"vertex_add_modal_proxy_placeholder": "e.g.: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "Edit Vertex API Configuration",
"vertex_edit_modal_key_label": "API Key:",
"vertex_edit_modal_url_label": "Base URL (Required):",
"vertex_edit_modal_proxy_label": "Proxy URL (Optional):",
"vertex_delete_confirm": "Are you sure you want to delete this Vertex configuration?",
"vertex_models_label": "Model mappings (alias required):",
"vertex_models_add_btn": "Add Mapping",
"vertex_models_hint": "Each mapping needs both the original model and its alias.",
"vertex_models_count": "Mapping count",
"ampcode_title": "Amp CLI Integration (ampcode)", "ampcode_title": "Amp CLI Integration (ampcode)",
"ampcode_modal_title": "Configure Ampcode", "ampcode_modal_title": "Configure Ampcode",
"ampcode_upstream_url_label": "Upstream URL", "ampcode_upstream_url_label": "Upstream URL",
@@ -468,6 +500,34 @@
"upgrade_required_title": "Please upgrade CLI Proxy API", "upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version." "upgrade_required_desc": "The current server does not support the OAuth excluded models API. Please upgrade to the latest CLI Proxy API (CPA) version."
}, },
"oauth_model_mappings": {
"title": "OAuth Model Mappings",
"add": "Add Mapping",
"add_title": "Add provider model mappings",
"provider_label": "Provider",
"provider_placeholder": "e.g. gemini-cli / vertex",
"provider_hint": "Defaults to the current filter; pick an existing provider or type a new name.",
"mappings_label": "Model mappings",
"mapping_name_placeholder": "Source model name",
"mapping_alias_placeholder": "Alias (required)",
"mapping_fork_label": "Keep original",
"mappings_hint": "Saving an empty list removes that provider. Enable “Keep original” to keep the original name while adding the alias.",
"add_mapping": "Add mapping",
"save": "Save/Update",
"save_success": "Model mappings updated",
"save_failed": "Failed to update model mappings",
"delete": "Delete Provider",
"delete_confirm": "Delete model mappings for {{provider}}?",
"delete_success": "Model mappings removed",
"delete_failed": "Failed to delete model mappings",
"no_models": "No model mappings",
"model_count": "{{count}} mappings",
"list_empty_all": "No model mappings yet—use “Add Mapping” to create one.",
"provider_required": "Please enter a provider first",
"upgrade_required": "This feature requires a newer CLI Proxy API (CPA) version. Please upgrade.",
"upgrade_required_title": "Please upgrade CLI Proxy API",
"upgrade_required_desc": "The current server does not support the OAuth model mappings API. Please upgrade to the latest CLI Proxy API (CPA) version."
},
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "Start Codex Login", "codex_oauth_button": "Start Codex Login",
@@ -552,7 +612,7 @@
"iflow_oauth_polling_error": "Failed to check authentication status:", "iflow_oauth_polling_error": "Failed to check authentication status:",
"iflow_cookie_title": "iFlow Cookie Login", "iflow_cookie_title": "iFlow Cookie Login",
"iflow_cookie_label": "Cookie Value:", "iflow_cookie_label": "Cookie Value:",
"iflow_cookie_placeholder": "Paste browser cookie, e.g. sessionid=...;", "iflow_cookie_placeholder": "Enter the BXAuth value, starting with BXAuth=",
"iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.", "iflow_cookie_hint": "Submit an existing cookie to finish login without opening the authorization link; the credential file will be saved automatically.",
"iflow_cookie_key_hint": "Note: Create a key on the platform first.", "iflow_cookie_key_hint": "Note: Create a key on the platform first.",
"iflow_cookie_button": "Submit Cookie Login", "iflow_cookie_button": "Submit Cookie Login",
@@ -766,8 +826,11 @@
"quota_switch_preview_updated": "Preview model switch settings updated", "quota_switch_preview_updated": "Preview model switch settings updated",
"usage_statistics_updated": "Usage statistics settings updated", "usage_statistics_updated": "Usage statistics settings updated",
"logging_to_file_updated": "Logging settings updated", "logging_to_file_updated": "Logging settings updated",
"logs_max_total_size_updated": "Log size limit updated",
"request_log_updated": "Request logging setting updated", "request_log_updated": "Request logging setting updated",
"force_model_prefix_updated": "Model prefix setting updated",
"ws_auth_updated": "WebSocket authentication setting updated", "ws_auth_updated": "WebSocket authentication setting updated",
"routing_strategy_updated": "Routing strategy updated",
"login_storage_cleared": "Local login data cleared", "login_storage_cleared": "Local login data cleared",
"api_key_added": "API key added successfully", "api_key_added": "API key added successfully",
"api_key_updated": "API key updated successfully", "api_key_updated": "API key updated successfully",
@@ -786,6 +849,10 @@
"claude_config_added": "Claude configuration added successfully", "claude_config_added": "Claude configuration added successfully",
"claude_config_updated": "Claude configuration updated successfully", "claude_config_updated": "Claude configuration updated successfully",
"claude_config_deleted": "Claude configuration deleted successfully", "claude_config_deleted": "Claude configuration deleted successfully",
"vertex_config_added": "Vertex configuration added successfully",
"vertex_config_updated": "Vertex configuration updated successfully",
"vertex_config_deleted": "Vertex configuration deleted successfully",
"vertex_base_url_required": "Please enter the Vertex Base URL",
"config_enabled": "Configuration enabled", "config_enabled": "Configuration enabled",
"config_disabled": "Configuration disabled", "config_disabled": "Configuration disabled",
"field_required": "Required fields cannot be empty", "field_required": "Required fields cannot be empty",

View File

@@ -137,11 +137,22 @@
"usage_statistics_enable": "启用使用统计", "usage_statistics_enable": "启用使用统计",
"logging_title": "日志记录", "logging_title": "日志记录",
"logging_to_file_enable": "启用日志记录到文件", "logging_to_file_enable": "启用日志记录到文件",
"logs_max_total_size_title": "日志容量限制",
"logs_max_total_size_label": "日志总大小上限 (MB):",
"logs_max_total_size_hint": "设置为 0 表示不限制。",
"logs_max_total_size_update": "更新",
"request_log_title": "请求日志", "request_log_title": "请求日志",
"request_log_enable": "启用请求日志", "request_log_enable": "启用请求日志",
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。", "request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
"force_model_prefix_enable": "强制模型前缀",
"ws_auth_title": "WebSocket 鉴权", "ws_auth_title": "WebSocket 鉴权",
"ws_auth_enable": "启用 /ws/* 鉴权" "ws_auth_enable": "启用 /ws/* 鉴权",
"routing_title": "路由策略",
"routing_strategy_label": "路由策略:",
"routing_strategy_hint": "round-robin 为轮询fill-first 为优先填充。",
"routing_strategy_update": "更新",
"routing_strategy_round_robin": "round-robin (轮询)",
"routing_strategy_fill_first": "fill-first (优先填充)"
}, },
"api_keys": { "api_keys": {
"title": "API 密钥管理", "title": "API 密钥管理",
@@ -221,6 +232,27 @@
"claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。", "claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。",
"claude_models_add_btn": "添加模型", "claude_models_add_btn": "添加模型",
"claude_models_count": "模型数量", "claude_models_count": "模型数量",
"vertex_title": "Vertex API 配置",
"vertex_add_button": "添加配置",
"vertex_empty_title": "暂无Vertex配置",
"vertex_empty_desc": "点击上方按钮添加第一个配置",
"vertex_item_title": "Vertex配置",
"vertex_add_modal_title": "添加Vertex API配置",
"vertex_add_modal_key_label": "API密钥:",
"vertex_add_modal_key_placeholder": "请输入Vertex API密钥",
"vertex_add_modal_url_label": "Base URL (必填):",
"vertex_add_modal_url_placeholder": "例如: https://example.com/api",
"vertex_add_modal_proxy_label": "代理 URL (可选):",
"vertex_add_modal_proxy_placeholder": "例如: socks5://proxy.example.com:1080",
"vertex_edit_modal_title": "编辑Vertex API配置",
"vertex_edit_modal_key_label": "API密钥:",
"vertex_edit_modal_url_label": "Base URL (必填):",
"vertex_edit_modal_proxy_label": "代理 URL (可选):",
"vertex_delete_confirm": "确定要删除这个Vertex配置吗",
"vertex_models_label": "模型映射 (别名必填):",
"vertex_models_add_btn": "添加映射",
"vertex_models_hint": "每条映射需要填写原模型与别名。",
"vertex_models_count": "映射数量",
"ampcode_title": "Amp CLI 集成 (ampcode)", "ampcode_title": "Amp CLI 集成 (ampcode)",
"ampcode_modal_title": "配置 Ampcode", "ampcode_modal_title": "配置 Ampcode",
"ampcode_upstream_url_label": "Upstream URL", "ampcode_upstream_url_label": "Upstream URL",
@@ -468,6 +500,34 @@
"upgrade_required_title": "需要升级 CPA 版本", "upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。" "upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPACLI Proxy API后重试。"
}, },
"oauth_model_mappings": {
"title": "OAuth 模型映射",
"add": "新增映射",
"add_title": "新增提供商模型映射",
"provider_label": "提供商",
"provider_placeholder": "例如 gemini-cli / vertex",
"provider_hint": "默认选中当前筛选的提供商,也可直接输入或选择其他名称。",
"mappings_label": "模型映射",
"mapping_name_placeholder": "原模型名称",
"mapping_alias_placeholder": "别名 (必填)",
"mapping_fork_label": "保留原名",
"mappings_hint": "留空保存将删除该提供商记录;开启“保留原名”会在保留原模型名的同时新增别名。",
"add_mapping": "添加映射",
"save": "保存/更新",
"save_success": "模型映射已更新",
"save_failed": "更新模型映射失败",
"delete": "删除提供商",
"delete_confirm": "确定要删除 {{provider}} 的模型映射吗?",
"delete_success": "已删除该提供商的模型映射",
"delete_failed": "删除模型映射失败",
"no_models": "未配置模型映射",
"model_count": "映射 {{count}} 条模型",
"list_empty_all": "暂无任何提供商的模型映射,点击“新增映射”创建。",
"provider_required": "请先填写提供商名称",
"upgrade_required": "当前 CPA 版本不支持模型映射功能,请升级 CPA 版本",
"upgrade_required_title": "需要升级 CPA 版本",
"upgrade_required_desc": "当前服务器版本不支持 OAuth 模型映射功能,请升级到最新版本的 CPACLI Proxy API后重试。"
},
"auth_login": { "auth_login": {
"codex_oauth_title": "Codex OAuth", "codex_oauth_title": "Codex OAuth",
"codex_oauth_button": "开始 Codex 登录", "codex_oauth_button": "开始 Codex 登录",
@@ -552,7 +612,7 @@
"iflow_oauth_polling_error": "检查认证状态失败:", "iflow_oauth_polling_error": "检查认证状态失败:",
"iflow_cookie_title": "iFlow Cookie 登录", "iflow_cookie_title": "iFlow Cookie 登录",
"iflow_cookie_label": "Cookie 内容:", "iflow_cookie_label": "Cookie 内容:",
"iflow_cookie_placeholder": "粘贴浏览器中的 Cookie例如 sessionid=...;", "iflow_cookie_placeholder": "填入BXAuth值 以BXAuth=开头",
"iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。", "iflow_cookie_hint": "直接提交 Cookie 以完成登录(无需打开授权链接),服务端将自动保存凭据。",
"iflow_cookie_key_hint": "提示:需在平台上先创建 Key。", "iflow_cookie_key_hint": "提示:需在平台上先创建 Key。",
"iflow_cookie_button": "提交 Cookie 登录", "iflow_cookie_button": "提交 Cookie 登录",
@@ -766,8 +826,11 @@
"quota_switch_preview_updated": "预览模型切换设置已更新", "quota_switch_preview_updated": "预览模型切换设置已更新",
"usage_statistics_updated": "使用统计设置已更新", "usage_statistics_updated": "使用统计设置已更新",
"logging_to_file_updated": "日志记录设置已更新", "logging_to_file_updated": "日志记录设置已更新",
"logs_max_total_size_updated": "日志容量设置已更新",
"request_log_updated": "请求日志设置已更新", "request_log_updated": "请求日志设置已更新",
"force_model_prefix_updated": "模型前缀设置已更新",
"ws_auth_updated": "WebSocket 鉴权设置已更新", "ws_auth_updated": "WebSocket 鉴权设置已更新",
"routing_strategy_updated": "路由策略已更新",
"login_storage_cleared": "本地登录信息已清理", "login_storage_cleared": "本地登录信息已清理",
"api_key_added": "API密钥添加成功", "api_key_added": "API密钥添加成功",
"api_key_updated": "API密钥更新成功", "api_key_updated": "API密钥更新成功",
@@ -786,6 +849,10 @@
"claude_config_added": "Claude配置添加成功", "claude_config_added": "Claude配置添加成功",
"claude_config_updated": "Claude配置更新成功", "claude_config_updated": "Claude配置更新成功",
"claude_config_deleted": "Claude配置删除成功", "claude_config_deleted": "Claude配置删除成功",
"vertex_config_added": "Vertex配置添加成功",
"vertex_config_updated": "Vertex配置更新成功",
"vertex_config_deleted": "Vertex配置删除成功",
"vertex_base_url_required": "请填写Vertex Base URL",
"config_enabled": "配置已启用", "config_enabled": "配置已启用",
"config_disabled": "配置已停用", "config_disabled": "配置已停用",
"field_required": "必填字段不能为空", "field_required": "必填字段不能为空",

View File

@@ -7,11 +7,13 @@ import {
CodexSection, CodexSection,
GeminiSection, GeminiSection,
OpenAISection, OpenAISection,
VertexSection,
useProviderStats, useProviderStats,
type GeminiFormState, type GeminiFormState,
type OpenAIFormState, type OpenAIFormState,
type ProviderFormState, type ProviderFormState,
type ProviderModal, type ProviderModal,
type VertexFormState,
} from '@/components/providers'; } from '@/components/providers';
import { import {
parseExcludedModels, parseExcludedModels,
@@ -21,7 +23,7 @@ import {
import { ampcodeApi, providersApi } from '@/services/api'; import { ampcodeApi, providersApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores'; import { useAuthStore, useConfigStore, useNotificationStore, useThemeStore } from '@/stores';
import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types';
import { buildHeaderObject, headersToEntries } from '@/utils/headers'; import { buildHeaderObject } from '@/utils/headers';
import styles from './AiProvidersPage.module.scss'; import styles from './AiProvidersPage.module.scss';
export function AiProvidersPage() { export function AiProvidersPage() {
@@ -41,6 +43,7 @@ export function AiProvidersPage() {
const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]); const [geminiKeys, setGeminiKeys] = useState<GeminiKeyConfig[]>([]);
const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]); const [codexConfigs, setCodexConfigs] = useState<ProviderKeyConfig[]>([]);
const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]); const [claudeConfigs, setClaudeConfigs] = useState<ProviderKeyConfig[]>([]);
const [vertexConfigs, setVertexConfigs] = useState<ProviderKeyConfig[]>([]);
const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]); const [openaiProviders, setOpenaiProviders] = useState<OpenAIProviderConfig[]>([]);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -63,17 +66,32 @@ export function AiProvidersPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const data = await fetchConfig(); const [configResult, vertexResult, ampcodeResult] = await Promise.allSettled([
fetchConfig(),
providersApi.getVertexConfigs(),
ampcodeApi.getAmpcode(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value;
setGeminiKeys(data?.geminiApiKeys || []); setGeminiKeys(data?.geminiApiKeys || []);
setCodexConfigs(data?.codexApiKeys || []); setCodexConfigs(data?.codexApiKeys || []);
setClaudeConfigs(data?.claudeApiKeys || []); setClaudeConfigs(data?.claudeApiKeys || []);
setVertexConfigs(data?.vertexApiKeys || []);
setOpenaiProviders(data?.openaiCompatibility || []); setOpenaiProviders(data?.openaiCompatibility || []);
try {
const ampcode = await ampcodeApi.getAmpcode(); if (vertexResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcode); setVertexConfigs(vertexResult.value || []);
updateConfigValue('vertex-api-key', vertexResult.value || []);
clearCache('vertex-api-key');
}
if (ampcodeResult.status === 'fulfilled') {
updateConfigValue('ampcode', ampcodeResult.value);
clearCache('ampcode'); clearCache('ampcode');
} catch {
// ignore
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = getErrorMessage(err) || t('notification.refresh_failed'); const message = getErrorMessage(err) || t('notification.refresh_failed');
@@ -92,11 +110,13 @@ export function AiProvidersPage() {
if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys); if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys);
if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys); if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys);
if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys); if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys);
if (config?.vertexApiKeys) setVertexConfigs(config.vertexApiKeys);
if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility); if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility);
}, [ }, [
config?.geminiApiKeys, config?.geminiApiKeys,
config?.codexApiKeys, config?.codexApiKeys,
config?.claudeApiKeys, config?.claudeApiKeys,
config?.vertexApiKeys,
config?.openaiCompatibility, config?.openaiCompatibility,
]); ]);
@@ -112,6 +132,10 @@ export function AiProvidersPage() {
setModal({ type, index }); setModal({ type, index });
}; };
const openVertexModal = (index: number | null) => {
setModal({ type: 'vertex', index });
};
const openAmpcodeModal = () => { const openAmpcodeModal = () => {
setModal({ type: 'ampcode', index: null }); setModal({ type: 'ampcode', index: null });
}; };
@@ -127,7 +151,7 @@ export function AiProvidersPage() {
apiKey: form.apiKey.trim(), apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined, prefix: form.prefix?.trim() || undefined,
baseUrl: form.baseUrl?.trim() || undefined, baseUrl: form.baseUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(form.headers)), headers: buildHeaderObject(form.headers),
excludedModels: parseExcludedModels(form.excludedText), excludedModels: parseExcludedModels(form.excludedText),
}; };
const nextList = const nextList =
@@ -283,7 +307,7 @@ export function AiProvidersPage() {
prefix: form.prefix?.trim() || undefined, prefix: form.prefix?.trim() || undefined,
baseUrl, baseUrl,
proxyUrl: form.proxyUrl?.trim() || undefined, proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(headersToEntries(form.headers)), headers: buildHeaderObject(form.headers),
models: entriesToModels(form.modelEntries), models: entriesToModels(form.modelEntries),
excludedModels: parseExcludedModels(form.excludedText), excludedModels: parseExcludedModels(form.excludedText),
}; };
@@ -351,6 +375,72 @@ export function AiProvidersPage() {
} }
}; };
const saveVertex = async (form: VertexFormState, editIndex: number | null) => {
const trimmedBaseUrl = (form.baseUrl ?? '').trim();
const baseUrl = trimmedBaseUrl || undefined;
if (!baseUrl) {
showNotification(t('notification.vertex_base_url_required'), 'error');
return;
}
setSaving(true);
try {
const payload: ProviderKeyConfig = {
apiKey: form.apiKey.trim(),
prefix: form.prefix?.trim() || undefined,
baseUrl,
proxyUrl: form.proxyUrl?.trim() || undefined,
headers: buildHeaderObject(form.headers),
models: form.modelEntries
.map((entry) => {
const name = entry.name.trim();
const alias = entry.alias.trim();
if (!name || !alias) return null;
return { name, alias };
})
.filter(Boolean) as ProviderKeyConfig['models'],
};
const nextList =
editIndex !== null
? vertexConfigs.map((item, idx) => (idx === editIndex ? payload : item))
: [...vertexConfigs, payload];
await providersApi.saveVertexConfigs(nextList);
setVertexConfigs(nextList);
updateConfigValue('vertex-api-key', nextList);
clearCache('vertex-api-key');
const message =
editIndex !== null
? t('notification.vertex_config_updated')
: t('notification.vertex_config_added');
showNotification(message, 'success');
closeModal();
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.update_failed')}: ${message}`, 'error');
} finally {
setSaving(false);
}
};
const deleteVertex = async (index: number) => {
const entry = vertexConfigs[index];
if (!entry) return;
if (!window.confirm(t('ai_providers.vertex_delete_confirm'))) return;
try {
await providersApi.deleteVertexConfig(entry.apiKey);
const next = vertexConfigs.filter((_, idx) => idx !== index);
setVertexConfigs(next);
updateConfigValue('vertex-api-key', next);
clearCache('vertex-api-key');
showNotification(t('notification.vertex_config_deleted'), 'success');
} catch (err: unknown) {
const message = getErrorMessage(err);
showNotification(`${t('notification.delete_failed')}: ${message}`, 'error');
}
};
const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => { const saveOpenai = async (form: OpenAIFormState, editIndex: number | null) => {
setSaving(true); setSaving(true);
try { try {
@@ -412,6 +502,7 @@ export function AiProvidersPage() {
const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null; const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null;
const codexModalIndex = modal?.type === 'codex' ? modal.index : null; const codexModalIndex = modal?.type === 'codex' ? modal.index : null;
const claudeModalIndex = modal?.type === 'claude' ? modal.index : null; const claudeModalIndex = modal?.type === 'claude' ? modal.index : null;
const vertexModalIndex = modal?.type === 'vertex' ? modal.index : null;
const openaiModalIndex = modal?.type === 'openai' ? modal.index : null; const openaiModalIndex = modal?.type === 'openai' ? modal.index : null;
return ( return (
@@ -475,6 +566,23 @@ export function AiProvidersPage() {
onSave={(form, editIndex) => saveProvider('claude', form, editIndex)} onSave={(form, editIndex) => saveProvider('claude', form, editIndex)}
/> />
<VertexSection
configs={vertexConfigs}
keyStats={keyStats}
usageDetails={usageDetails}
loading={loading}
disableControls={disableControls}
isSaving={saving}
isSwitching={isSwitching}
isModalOpen={modal?.type === 'vertex'}
modalIndex={vertexModalIndex}
onAdd={() => openVertexModal(null)}
onEdit={(index) => openVertexModal(index)}
onDelete={deleteVertex}
onCloseModal={closeModal}
onSave={saveVertex}
/>
<AmpcodeSection <AmpcodeSection
config={config?.ampcode} config={config?.ampcode}
loading={loading} loading={loading}

View File

@@ -751,6 +751,32 @@
} }
} }
// OAuth 模型映射表单
.mappingRow {
display: grid;
grid-template-columns: 1fr auto 1fr auto auto;
align-items: center;
gap: $spacing-sm;
@include mobile {
grid-template-columns: 1fr;
}
}
.mappingSeparator {
color: var(--text-secondary);
text-align: center;
@include mobile {
display: none;
}
}
.mappingFork {
display: flex;
align-items: center;
}
// 详情弹窗 // 详情弹窗
.detailContent { .detailContent {
max-height: 400px; max-height: 400px;

View File

@@ -4,15 +4,16 @@ import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { Card } from '@/components/ui/Card'; import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; import { LoadingSpinner } from '@/components/ui/LoadingSpinner';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/Input';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { EmptyState } from '@/components/ui/EmptyState'; import { EmptyState } from '@/components/ui/EmptyState';
import { IconBot, IconDownload, IconInfo, IconTrash2 } from '@/components/ui/icons'; import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores'; import { IconBot, IconDownload, IconInfo, IconTrash2, IconX } from '@/components/ui/icons';
import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import { authFilesApi, usageApi } from '@/services/api'; import { authFilesApi, usageApi } from '@/services/api';
import { apiClient } from '@/services/api/client'; import { apiClient } from '@/services/api/client';
import type { AuthFileItem } from '@/types'; import type { AuthFileItem, OAuthModelMappingEntry } from '@/types';
import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage'; import type { KeyStats, KeyStatBucket, UsageDetail } from '@/utils/usage';
import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage'; import { collectUsageDetails, calculateStatusBarData } from '@/utils/usage';
import { formatFileSize } from '@/utils/format'; import { formatFileSize } from '@/utils/format';
@@ -67,7 +68,6 @@ const TYPE_COLORS: Record<string, TypeColorSet> = {
}; };
const OAUTH_PROVIDER_PRESETS = [ const OAUTH_PROVIDER_PRESETS = [
'gemini',
'gemini-cli', 'gemini-cli',
'vertex', 'vertex',
'aistudio', 'aistudio',
@@ -90,6 +90,17 @@ interface ExcludedFormState {
provider: string; provider: string;
modelsText: string; modelsText: string;
} }
interface ModelMappingsFormState {
provider: string;
mappings: OAuthModelMappingEntry[];
}
const buildEmptyMappingEntry = (): OAuthModelMappingEntry => ({
name: '',
alias: '',
fork: false
});
// 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) // 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致)
function normalizeAuthIndexValue(value: unknown): string | null { function normalizeAuthIndexValue(value: unknown): string | null {
if (typeof value === 'number' && Number.isFinite(value)) { if (typeof value === 'number' && Number.isFinite(value)) {
@@ -148,11 +159,11 @@ function resolveAuthFileStats(
return defaultStats; return defaultStats;
} }
export function AuthFilesPage() { export function AuthFilesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { showNotification } = useNotificationStore(); const { showNotification } = useNotificationStore();
const connectionStatus = useAuthStore((state) => state.connectionStatus); const connectionStatus = useAuthStore((state) => state.connectionStatus);
const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme); const resolvedTheme: ResolvedTheme = useThemeStore((state) => state.resolvedTheme);
const [files, setFiles] = useState<AuthFileItem[]>([]); const [files, setFiles] = useState<AuthFileItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -181,17 +192,30 @@ export function AuthFilesPage() {
// OAuth 排除模型相关 // OAuth 排除模型相关
const [excluded, setExcluded] = useState<Record<string, string[]>>({}); const [excluded, setExcluded] = useState<Record<string, string[]>>({});
const [excludedError, setExcludedError] = useState<'unsupported' | null>(null); const [excludedError, setExcludedError] = useState<'unsupported' | null>(null);
const [excludedModalOpen, setExcludedModalOpen] = useState(false); const [excludedModalOpen, setExcludedModalOpen] = useState(false);
const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' }); const [excludedForm, setExcludedForm] = useState<ExcludedFormState>({ provider: '', modelsText: '' });
const [savingExcluded, setSavingExcluded] = useState(false); const [savingExcluded, setSavingExcluded] = useState(false);
// OAuth 模型映射相关
const [modelMappings, setModelMappings] = useState<Record<string, OAuthModelMappingEntry[]>>({});
const [modelMappingsError, setModelMappingsError] = useState<'unsupported' | null>(null);
const [mappingModalOpen, setMappingModalOpen] = useState(false);
const [mappingForm, setMappingForm] = useState<ModelMappingsFormState>({
provider: '',
mappings: [buildEmptyMappingEntry()]
});
const [savingMappings, setSavingMappings] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null); const fileInputRef = useRef<HTMLInputElement | null>(null);
const loadingKeyStatsRef = useRef(false); const loadingKeyStatsRef = useRef(false);
const excludedUnsupportedRef = useRef(false); const excludedUnsupportedRef = useRef(false);
const mappingsUnsupportedRef = useRef(false);
const disableControls = connectionStatus !== 'connected'; const disableControls = connectionStatus !== 'connected';
const normalizeProviderKey = (value: string) => value.trim().toLowerCase();
const handlePageSizeChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handlePageSizeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.valueAsNumber; const value = event.currentTarget.valueAsNumber;
if (!Number.isFinite(value)) return; if (!Number.isFinite(value)) return;
@@ -252,7 +276,7 @@ export function AuthFilesPage() {
const res = await authFilesApi.getOauthExcludedModels(); const res = await authFilesApi.getOauthExcludedModels();
excludedUnsupportedRef.current = false; excludedUnsupportedRef.current = false;
setExcluded(res || {}); setExcluded(res || {});
setExcludedError(null); setExcludedError(null);
} catch (err: unknown) { } catch (err: unknown) {
const status = const status =
typeof err === 'object' && err !== null && 'status' in err typeof err === 'object' && err !== null && 'status' in err
@@ -272,9 +296,35 @@ export function AuthFilesPage() {
} }
}, [showNotification, t]); }, [showNotification, t]);
// 加载 OAuth 模型映射
const loadModelMappings = useCallback(async () => {
try {
const res = await authFilesApi.getOauthModelMappings();
mappingsUnsupportedRef.current = false;
setModelMappings(res || {});
setModelMappingsError(null);
} catch (err: unknown) {
const status =
typeof err === 'object' && err !== null && 'status' in err
? (err as { status?: unknown }).status
: undefined;
if (status === 404) {
setModelMappings({});
setModelMappingsError('unsupported');
if (!mappingsUnsupportedRef.current) {
mappingsUnsupportedRef.current = true;
showNotification(t('oauth_model_mappings.upgrade_required'), 'warning');
}
return;
}
// 静默失败
}
}, [showNotification, t]);
const handleHeaderRefresh = useCallback(async () => { const handleHeaderRefresh = useCallback(async () => {
await Promise.all([loadFiles(), loadKeyStats(), loadExcluded()]); await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelMappings()]);
}, [loadFiles, loadKeyStats, loadExcluded]); }, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
useHeaderRefresh(handleHeaderRefresh); useHeaderRefresh(handleHeaderRefresh);
@@ -282,7 +332,8 @@ export function AuthFilesPage() {
loadFiles(); loadFiles();
loadKeyStats(); loadKeyStats();
loadExcluded(); loadExcluded();
}, [loadFiles, loadKeyStats, loadExcluded]); loadModelMappings();
}, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]);
// 定时刷新状态数据每240秒 // 定时刷新状态数据每240秒
useInterval(loadKeyStats, 240_000); useInterval(loadKeyStats, 240_000);
@@ -310,12 +361,26 @@ export function AuthFilesPage() {
return lookup; return lookup;
}, [excluded]); }, [excluded]);
const mappingProviderLookup = useMemo(() => {
const lookup = new Map<string, string>();
Object.keys(modelMappings).forEach((provider) => {
const key = provider.trim().toLowerCase();
if (key && !lookup.has(key)) {
lookup.set(key, provider);
}
});
return lookup;
}, [modelMappings]);
const providerOptions = useMemo(() => { const providerOptions = useMemo(() => {
const extraProviders = new Set<string>(); const extraProviders = new Set<string>();
Object.keys(excluded).forEach((provider) => { Object.keys(excluded).forEach((provider) => {
extraProviders.add(provider); extraProviders.add(provider);
}); });
Object.keys(modelMappings).forEach((provider) => {
extraProviders.add(provider);
});
files.forEach((file) => { files.forEach((file) => {
if (typeof file.type === 'string') { if (typeof file.type === 'string') {
extraProviders.add(file.type); extraProviders.add(file.type);
@@ -335,7 +400,7 @@ export function AuthFilesPage() {
.sort((a, b) => a.localeCompare(b)); .sort((a, b) => a.localeCompare(b));
return [...OAUTH_PROVIDER_PRESETS, ...extraList]; return [...OAUTH_PROVIDER_PRESETS, ...extraList];
}, [excluded, files]); }, [excluded, files, modelMappings]);
// 过滤和搜索 // 过滤和搜索
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -562,13 +627,14 @@ export function AuthFilesPage() {
}; };
// 检查模型是否被 OAuth 排除 // 检查模型是否被 OAuth 排除
const isModelExcluded = (modelId: string, providerType: string): boolean => { const isModelExcluded = (modelId: string, providerType: string): boolean => {
const excludedModels = excluded[providerType] || []; const providerKey = normalizeProviderKey(providerType);
return excludedModels.some(pattern => { const excludedModels = excluded[providerKey] || excluded[providerType] || [];
if (pattern.includes('*')) { return excludedModels.some(pattern => {
// 支持通配符匹配 if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i'); // 支持通配符匹配
return regex.test(modelId); const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i');
return regex.test(modelId);
} }
return pattern.toLowerCase() === modelId.toLowerCase(); return pattern.toLowerCase() === modelId.toLowerCase();
}); });
@@ -591,11 +657,10 @@ export function AuthFilesPage() {
// OAuth 排除相关方法 // OAuth 排除相关方法
const openExcludedModal = (provider?: string) => { const openExcludedModal = (provider?: string) => {
const normalizedProvider = (provider || '').trim(); const normalizedProvider = normalizeProviderKey(provider || '');
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : ''); const fallbackProvider =
const lookupKey = fallbackProvider normalizedProvider || (filter !== 'all' ? normalizeProviderKey(String(filter)) : '');
? excludedProviderLookup.get(fallbackProvider.toLowerCase()) const lookupKey = fallbackProvider ? excludedProviderLookup.get(fallbackProvider) : undefined;
: undefined;
const models = lookupKey ? excluded[lookupKey] : []; const models = lookupKey ? excluded[lookupKey] : [];
setExcludedForm({ setExcludedForm({
provider: lookupKey || fallbackProvider, provider: lookupKey || fallbackProvider,
@@ -604,45 +669,165 @@ export function AuthFilesPage() {
setExcludedModalOpen(true); setExcludedModalOpen(true);
}; };
const saveExcludedModels = async () => { const saveExcludedModels = async () => {
const provider = excludedForm.provider.trim(); const provider = normalizeProviderKey(excludedForm.provider);
if (!provider) { if (!provider) {
showNotification(t('oauth_excluded.provider_required'), 'error'); showNotification(t('oauth_excluded.provider_required'), 'error');
return; return;
} }
const models = excludedForm.modelsText const models = excludedForm.modelsText
.split(/[\n,]+/) .split(/[\n,]+/)
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean); .filter(Boolean);
setSavingExcluded(true); setSavingExcluded(true);
try { try {
if (models.length) { if (models.length) {
await authFilesApi.saveOauthExcludedModels(provider, models); await authFilesApi.saveOauthExcludedModels(provider, models);
} else { } else {
await authFilesApi.deleteOauthExcludedEntry(provider); await authFilesApi.deleteOauthExcludedEntry(provider);
} }
await loadExcluded(); await loadExcluded();
showNotification(t('oauth_excluded.save_success'), 'success'); showNotification(t('oauth_excluded.save_success'), 'success');
setExcludedModalOpen(false); setExcludedModalOpen(false);
} catch (err: unknown) { } catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : ''; const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error'); showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error');
} finally { } finally {
setSavingExcluded(false); setSavingExcluded(false);
} }
}; };
const deleteExcluded = async (provider: string) => { const deleteExcluded = async (provider: string) => {
if (!window.confirm(t('oauth_excluded.delete_confirm', { provider }))) return; const providerLabel = provider.trim() || provider;
try { if (!window.confirm(t('oauth_excluded.delete_confirm', { provider: providerLabel }))) return;
await authFilesApi.deleteOauthExcludedEntry(provider); const providerKey = normalizeProviderKey(provider);
await loadExcluded(); if (!providerKey) {
showNotification(t('oauth_excluded.delete_success'), 'success'); showNotification(t('oauth_excluded.provider_required'), 'error');
} catch (err: unknown) { return;
const errorMessage = err instanceof Error ? err.message : ''; }
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error'); try {
} await authFilesApi.deleteOauthExcludedEntry(providerKey);
}; await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (err: unknown) {
try {
const current = await authFilesApi.getOauthExcludedModels();
const next: Record<string, string[]> = {};
Object.entries(current).forEach(([key, models]) => {
if (normalizeProviderKey(key) === providerKey) return;
next[key] = models;
});
await authFilesApi.replaceOauthExcludedModels(next);
await loadExcluded();
showNotification(t('oauth_excluded.delete_success'), 'success');
} catch (fallbackErr: unknown) {
const errorMessage = fallbackErr instanceof Error ? fallbackErr.message : err instanceof Error ? err.message : '';
showNotification(`${t('oauth_excluded.delete_failed')}: ${errorMessage}`, 'error');
}
}
};
// OAuth 模型映射相关方法
const normalizeMappingEntries = (entries?: OAuthModelMappingEntry[]) => {
if (!Array.isArray(entries) || entries.length === 0) {
return [buildEmptyMappingEntry()];
}
return entries.map((entry) => ({
name: entry.name ?? '',
alias: entry.alias ?? '',
fork: Boolean(entry.fork),
}));
};
const openMappingsModal = (provider?: string) => {
const normalizedProvider = (provider || '').trim();
const fallbackProvider = normalizedProvider || (filter !== 'all' ? String(filter) : '');
const lookupKey = fallbackProvider
? mappingProviderLookup.get(fallbackProvider.toLowerCase())
: undefined;
const mappings = lookupKey ? modelMappings[lookupKey] : [];
setMappingForm({
provider: lookupKey || fallbackProvider,
mappings: normalizeMappingEntries(mappings),
});
setMappingModalOpen(true);
};
const updateMappingEntry = (index: number, field: keyof OAuthModelMappingEntry, value: string | boolean) => {
setMappingForm((prev) => ({
...prev,
mappings: prev.mappings.map((entry, idx) =>
idx === index ? { ...entry, [field]: value } : entry
),
}));
};
const addMappingEntry = () => {
setMappingForm((prev) => ({
...prev,
mappings: [...prev.mappings, buildEmptyMappingEntry()],
}));
};
const removeMappingEntry = (index: number) => {
setMappingForm((prev) => {
const next = prev.mappings.filter((_, idx) => idx !== index);
return {
...prev,
mappings: next.length ? next : [buildEmptyMappingEntry()],
};
});
};
const saveModelMappings = async () => {
const provider = mappingForm.provider.trim();
if (!provider) {
showNotification(t('oauth_model_mappings.provider_required'), 'error');
return;
}
const seen = new Set<string>();
const mappings = mappingForm.mappings
.map((entry) => {
const name = String(entry.name ?? '').trim();
const alias = String(entry.alias ?? '').trim();
if (!name || !alias) return null;
const key = `${name.toLowerCase()}::${alias.toLowerCase()}::${entry.fork ? '1' : '0'}`;
if (seen.has(key)) return null;
seen.add(key);
return entry.fork ? { name, alias, fork: true } : { name, alias };
})
.filter(Boolean) as OAuthModelMappingEntry[];
setSavingMappings(true);
try {
if (mappings.length) {
await authFilesApi.saveOauthModelMappings(provider, mappings);
} else {
await authFilesApi.deleteOauthModelMappings(provider);
}
await loadModelMappings();
showNotification(t('oauth_model_mappings.save_success'), 'success');
setMappingModalOpen(false);
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_mappings.save_failed')}: ${errorMessage}`, 'error');
} finally {
setSavingMappings(false);
}
};
const deleteModelMappings = async (provider: string) => {
if (!window.confirm(t('oauth_model_mappings.delete_confirm', { provider }))) return;
try {
await authFilesApi.deleteOauthModelMappings(provider);
await loadModelMappings();
showNotification(t('oauth_model_mappings.delete_success'), 'success');
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : '';
showNotification(`${t('oauth_model_mappings.delete_failed')}: ${errorMessage}`, 'error');
}
};
// 渲染标签筛选器 // 渲染标签筛选器
const renderFilterTags = () => ( const renderFilterTags = () => (
@@ -959,14 +1144,14 @@ export function AuthFilesPage() {
title={t('oauth_excluded.title')} title={t('oauth_excluded.title')}
extra={ extra={
<Button <Button
size="sm" size="sm"
onClick={() => openExcludedModal()} onClick={() => openExcludedModal()}
disabled={disableControls || excludedError === 'unsupported'} disabled={disableControls || excludedError === 'unsupported'}
> >
{t('oauth_excluded.add')} {t('oauth_excluded.add')}
</Button> </Button>
} }
> >
{excludedError === 'unsupported' ? ( {excludedError === 'unsupported' ? (
<EmptyState <EmptyState
title={t('oauth_excluded.upgrade_required_title')} title={t('oauth_excluded.upgrade_required_title')}
@@ -997,12 +1182,58 @@ export function AuthFilesPage() {
</div> </div>
))} ))}
</div> </div>
)} )}
</Card> </Card>
{/* 详情弹窗 */} {/* OAuth 模型映射卡片 */}
<Modal <Card
open={detailModalOpen} title={t('oauth_model_mappings.title')}
extra={
<Button
size="sm"
onClick={() => openMappingsModal()}
disabled={disableControls || modelMappingsError === 'unsupported'}
>
{t('oauth_model_mappings.add')}
</Button>
}
>
{modelMappingsError === 'unsupported' ? (
<EmptyState
title={t('oauth_model_mappings.upgrade_required_title')}
description={t('oauth_model_mappings.upgrade_required_desc')}
/>
) : Object.keys(modelMappings).length === 0 ? (
<EmptyState title={t('oauth_model_mappings.list_empty_all')} />
) : (
<div className={styles.excludedList}>
{Object.entries(modelMappings).map(([provider, mappings]) => (
<div key={provider} className={styles.excludedItem}>
<div className={styles.excludedInfo}>
<div className={styles.excludedProvider}>{provider}</div>
<div className={styles.excludedModels}>
{mappings?.length
? t('oauth_model_mappings.model_count', { count: mappings.length })
: t('oauth_model_mappings.no_models')}
</div>
</div>
<div className={styles.excludedActions}>
<Button variant="secondary" size="sm" onClick={() => openMappingsModal(provider)}>
{t('common.edit')}
</Button>
<Button variant="danger" size="sm" onClick={() => deleteModelMappings(provider)}>
{t('oauth_model_mappings.delete')}
</Button>
</div>
</div>
))}
</div>
)}
</Card>
{/* 详情弹窗 */}
<Modal
open={detailModalOpen}
onClose={() => setDetailModalOpen(false)} onClose={() => setDetailModalOpen(false)}
title={selectedFile?.name || t('auth_files.title_section')} title={selectedFile?.name || t('auth_files.title_section')}
footer={ footer={
@@ -1146,9 +1377,117 @@ export function AuthFilesPage() {
value={excludedForm.modelsText} value={excludedForm.modelsText}
onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))} onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))}
/> />
<div className={styles.hint}>{t('oauth_excluded.models_hint')}</div> <div className={styles.hint}>{t('oauth_excluded.models_hint')}</div>
</div> </div>
</Modal> </Modal>
</div>
); {/* OAuth 模型映射弹窗 */}
} <Modal
open={mappingModalOpen}
onClose={() => setMappingModalOpen(false)}
title={t('oauth_model_mappings.add_title')}
footer={
<>
<Button variant="secondary" onClick={() => setMappingModalOpen(false)} disabled={savingMappings}>
{t('common.cancel')}
</Button>
<Button onClick={saveModelMappings} loading={savingMappings}>
{t('oauth_model_mappings.save')}
</Button>
</>
}
>
<div className={styles.providerField}>
<Input
id="oauth-model-mappings-provider"
list="oauth-model-mappings-provider-options"
label={t('oauth_model_mappings.provider_label')}
hint={t('oauth_model_mappings.provider_hint')}
placeholder={t('oauth_model_mappings.provider_placeholder')}
value={mappingForm.provider}
onChange={(e) => setMappingForm((prev) => ({ ...prev, provider: e.target.value }))}
/>
<datalist id="oauth-model-mappings-provider-options">
{providerOptions.map((provider) => (
<option key={provider} value={provider} />
))}
</datalist>
{providerOptions.length > 0 && (
<div className={styles.providerTagList}>
{providerOptions.map((provider) => {
const isActive =
mappingForm.provider.trim().toLowerCase() === provider.toLowerCase();
return (
<button
key={provider}
type="button"
className={`${styles.providerTag} ${isActive ? styles.providerTagActive : ''}`}
onClick={() => setMappingForm((prev) => ({ ...prev, provider }))}
disabled={savingMappings}
>
{getTypeLabel(provider)}
</button>
);
})}
</div>
)}
</div>
<div className={styles.formGroup}>
<label>{t('oauth_model_mappings.mappings_label')}</label>
<div className="header-input-list">
{(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map(
(entry, index) => (
<div key={`${entry.name}-${entry.alias}-${index}`} className={styles.mappingRow}>
<input
className="input"
placeholder={t('oauth_model_mappings.mapping_name_placeholder')}
value={entry.name}
onChange={(e) => updateMappingEntry(index, 'name', e.target.value)}
disabled={savingMappings}
/>
<span className={styles.mappingSeparator}></span>
<input
className="input"
placeholder={t('oauth_model_mappings.mapping_alias_placeholder')}
value={entry.alias}
onChange={(e) => updateMappingEntry(index, 'alias', e.target.value)}
disabled={savingMappings}
/>
<div className={styles.mappingFork}>
<ToggleSwitch
label={t('oauth_model_mappings.mapping_fork_label')}
labelPosition="left"
checked={Boolean(entry.fork)}
onChange={(value) => updateMappingEntry(index, 'fork', value)}
disabled={savingMappings}
/>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeMappingEntry(index)}
disabled={savingMappings || mappingForm.mappings.length <= 1}
title={t('common.delete')}
aria-label={t('common.delete')}
>
<IconX size={14} />
</Button>
</div>
)
)}
<Button
variant="secondary"
size="sm"
onClick={addMappingEntry}
disabled={savingMappings}
className="align-start"
>
{t('oauth_model_mappings.add_mapping')}
</Button>
</div>
<div className={styles.hint}>{t('oauth_model_mappings.mappings_hint')}</div>
</div>
</Modal>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState, useCallback } from 'react';
import { Navigate, useNavigate, useLocation } from 'react-router-dom'; import { Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
@@ -81,6 +81,13 @@ export function LoginPage() {
} }
}; };
const handleSubmitKeyDown = useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !loading) {
event.preventDefault();
handleSubmit();
}
}, [loading, handleSubmit]);
return ( return (
<div className="login-page"> <div className="login-page">
<div className="login-card"> <div className="login-card">
@@ -129,11 +136,13 @@ export function LoginPage() {
)} )}
<Input <Input
autoFocus
label={t('login.management_key_label')} label={t('login.management_key_label')}
placeholder={t('login.management_key_placeholder')} placeholder={t('login.management_key_placeholder')}
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
value={managementKey} value={managementKey}
onChange={(e) => setManagementKey(e.target.value)} onChange={(e) => setManagementKey(e.target.value)}
onKeyDown={handleSubmitKeyDown}
rightElement={ rightElement={
<button <button
type="button" type="button"

View File

@@ -122,6 +122,33 @@
} }
} }
.retryRowAligned {
align-items: flex-start;
.retryButton {
margin-top: calc(1.5em + #{$spacing-xs});
}
@include mobile {
align-items: stretch;
.retryButton {
margin-top: 0;
}
}
}
.retryRowInputGrow {
:global(.form-group) {
flex: 1 1 0;
min-width: 0;
}
.retryInput {
width: 100%;
}
}
.retryInput { .retryInput {
width: 140px; width: 140px;

View File

@@ -13,6 +13,9 @@ type PendingKey =
| 'debug' | 'debug'
| 'proxy' | 'proxy'
| 'retry' | 'retry'
| 'logsMaxSize'
| 'forceModelPrefix'
| 'routingStrategy'
| 'switchProject' | 'switchProject'
| 'switchPreview' | 'switchPreview'
| 'usage' | 'usage'
@@ -31,6 +34,8 @@ export function SettingsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [proxyValue, setProxyValue] = useState(''); const [proxyValue, setProxyValue] = useState('');
const [retryValue, setRetryValue] = useState(0); const [retryValue, setRetryValue] = useState(0);
const [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0);
const [routingStrategy, setRoutingStrategy] = useState('round-robin');
const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>); const [pending, setPending] = useState<Record<PendingKey, boolean>>({} as Record<PendingKey, boolean>);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -41,9 +46,34 @@ export function SettingsPage() {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const data = (await fetchConfig()) as Config; const [configResult, logsResult, prefixResult, routingResult] = await Promise.allSettled([
fetchConfig(),
configApi.getLogsMaxTotalSizeMb(),
configApi.getForceModelPrefix(),
configApi.getRoutingStrategy(),
]);
if (configResult.status !== 'fulfilled') {
throw configResult.reason;
}
const data = configResult.value as Config;
setProxyValue(data?.proxyUrl ?? ''); setProxyValue(data?.proxyUrl ?? '');
setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0); setRetryValue(typeof data?.requestRetry === 'number' ? data.requestRetry : 0);
if (logsResult.status === 'fulfilled' && Number.isFinite(logsResult.value)) {
setLogsMaxTotalSizeMb(Math.max(0, Number(logsResult.value)));
updateConfigValue('logs-max-total-size-mb', Math.max(0, Number(logsResult.value)));
}
if (prefixResult.status === 'fulfilled') {
updateConfigValue('force-model-prefix', Boolean(prefixResult.value));
}
if (routingResult.status === 'fulfilled' && routingResult.value) {
setRoutingStrategy(String(routingResult.value));
updateConfigValue('routing/strategy', String(routingResult.value));
}
} catch (err: any) { } catch (err: any) {
setError(err?.message || t('notification.refresh_failed')); setError(err?.message || t('notification.refresh_failed'));
} finally { } finally {
@@ -52,7 +82,7 @@ export function SettingsPage() {
}; };
load(); load();
}, [fetchConfig, t]); }, [fetchConfig, t, updateConfigValue]);
useEffect(() => { useEffect(() => {
if (config) { if (config) {
@@ -60,8 +90,14 @@ export function SettingsPage() {
if (typeof config.requestRetry === 'number') { if (typeof config.requestRetry === 'number') {
setRetryValue(config.requestRetry); setRetryValue(config.requestRetry);
} }
if (typeof config.logsMaxTotalSizeMb === 'number') {
setLogsMaxTotalSizeMb(config.logsMaxTotalSizeMb);
}
if (config.routingStrategy) {
setRoutingStrategy(config.routingStrategy);
}
} }
}, [config?.proxyUrl, config?.requestRetry]); }, [config?.proxyUrl, config?.requestRetry, config?.logsMaxTotalSizeMb, config?.routingStrategy]);
const setPendingFlag = (key: PendingKey, value: boolean) => { const setPendingFlag = (key: PendingKey, value: boolean) => {
setPending((prev) => ({ ...prev, [key]: value })); setPending((prev) => ({ ...prev, [key]: value }));
@@ -69,7 +105,7 @@ export function SettingsPage() {
const toggleSetting = async ( const toggleSetting = async (
section: PendingKey, section: PendingKey,
rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth', rawKey: 'debug' | 'usage-statistics-enabled' | 'logging-to-file' | 'ws-auth' | 'force-model-prefix',
value: boolean, value: boolean,
updater: (val: boolean) => Promise<any>, updater: (val: boolean) => Promise<any>,
successMessage: string successMessage: string
@@ -84,6 +120,8 @@ export function SettingsPage() {
return config?.loggingToFile ?? false; return config?.loggingToFile ?? false;
case 'ws-auth': case 'ws-auth':
return config?.wsAuth ?? false; return config?.wsAuth ?? false;
case 'force-model-prefix':
return config?.forceModelPrefix ?? false;
default: default:
return false; return false;
} }
@@ -162,6 +200,52 @@ export function SettingsPage() {
} }
}; };
const handleLogsMaxTotalSizeUpdate = async () => {
const previous = config?.logsMaxTotalSizeMb ?? 0;
const parsed = Number(logsMaxTotalSizeMb);
if (!Number.isFinite(parsed) || parsed < 0) {
showNotification(t('login.error_invalid'), 'error');
setLogsMaxTotalSizeMb(previous);
return;
}
const normalized = Math.max(0, parsed);
setPendingFlag('logsMaxSize', true);
updateConfigValue('logs-max-total-size-mb', normalized);
try {
await configApi.updateLogsMaxTotalSizeMb(normalized);
clearCache('logs-max-total-size-mb');
showNotification(t('notification.logs_max_total_size_updated'), 'success');
} catch (err: any) {
setLogsMaxTotalSizeMb(previous);
updateConfigValue('logs-max-total-size-mb', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('logsMaxSize', false);
}
};
const handleRoutingStrategyUpdate = async () => {
const strategy = routingStrategy.trim();
if (!strategy) {
showNotification(t('login.error_invalid'), 'error');
return;
}
const previous = config?.routingStrategy ?? 'round-robin';
setPendingFlag('routingStrategy', true);
updateConfigValue('routing/strategy', strategy);
try {
await configApi.updateRoutingStrategy(strategy);
clearCache('routing/strategy');
showNotification(t('notification.routing_strategy_updated'), 'success');
} catch (err: any) {
setRoutingStrategy(previous);
updateConfigValue('routing/strategy', previous);
showNotification(`${t('notification.update_failed')}: ${err?.message || ''}`, 'error');
} finally {
setPendingFlag('routingStrategy', false);
}
};
const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false; const quotaSwitchProject = config?.quotaExceeded?.switchProject ?? false;
const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false; const quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false;
@@ -171,63 +255,78 @@ export function SettingsPage() {
<div className={styles.grid}> <div className={styles.grid}>
<Card> <Card>
{error && <div className="error-box">{error}</div>} {error && <div className="error-box">{error}</div>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch <ToggleSwitch
label={t('basic_settings.debug_enable')} label={t('basic_settings.debug_enable')}
checked={config?.debug ?? false} checked={config?.debug ?? false}
disabled={disableControls || pending.debug || loading} disabled={disableControls || pending.debug || loading}
onChange={(value) => onChange={(value) =>
toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated')) toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated'))
} }
/> />
<ToggleSwitch <ToggleSwitch
label={t('basic_settings.usage_statistics_enable')} label={t('basic_settings.usage_statistics_enable')}
checked={config?.usageStatisticsEnabled ?? false} checked={config?.usageStatisticsEnabled ?? false}
disabled={disableControls || pending.usage || loading} disabled={disableControls || pending.usage || loading}
onChange={(value) => onChange={(value) =>
toggleSetting( toggleSetting(
'usage', 'usage',
'usage-statistics-enabled', 'usage-statistics-enabled',
value, value,
configApi.updateUsageStatistics, configApi.updateUsageStatistics,
t('notification.usage_statistics_updated') t('notification.usage_statistics_updated')
) )
} }
/> />
<ToggleSwitch <ToggleSwitch
label={t('basic_settings.logging_to_file_enable')} label={t('basic_settings.logging_to_file_enable')}
checked={config?.loggingToFile ?? false} checked={config?.loggingToFile ?? false}
disabled={disableControls || pending.loggingToFile || loading} disabled={disableControls || pending.loggingToFile || loading}
onChange={(value) => onChange={(value) =>
toggleSetting( toggleSetting(
'loggingToFile', 'loggingToFile',
'logging-to-file', 'logging-to-file',
value, value,
configApi.updateLoggingToFile, configApi.updateLoggingToFile,
t('notification.logging_to_file_updated') t('notification.logging_to_file_updated')
) )
} }
/> />
<ToggleSwitch <ToggleSwitch
label={t('basic_settings.ws_auth_enable')} label={t('basic_settings.ws_auth_enable')}
checked={config?.wsAuth ?? false} checked={config?.wsAuth ?? false}
disabled={disableControls || pending.wsAuth || loading} disabled={disableControls || pending.wsAuth || loading}
onChange={(value) => onChange={(value) =>
toggleSetting( toggleSetting(
'wsAuth', 'wsAuth',
'ws-auth', 'ws-auth',
value, value,
configApi.updateWsAuth, configApi.updateWsAuth,
t('notification.ws_auth_updated') t('notification.ws_auth_updated')
) )
} }
/> />
</div>
</Card> <ToggleSwitch
label={t('basic_settings.force_model_prefix_enable')}
checked={config?.forceModelPrefix ?? false}
disabled={disableControls || pending.forceModelPrefix || loading}
onChange={(value) =>
toggleSetting(
'forceModelPrefix',
'force-model-prefix',
value,
configApi.updateForceModelPrefix,
t('notification.force_model_prefix_updated')
)
}
/>
</div>
</Card>
<Card title={t('basic_settings.proxy_title')}> <Card title={t('basic_settings.proxy_title')}>
<Input <Input
@@ -271,6 +370,57 @@ export function SettingsPage() {
</div> </div>
</Card> </Card>
<Card title={t('basic_settings.logs_max_total_size_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<Input
label={t('basic_settings.logs_max_total_size_label')}
hint={t('basic_settings.logs_max_total_size_hint')}
type="number"
inputMode="numeric"
min={0}
step={1}
value={logsMaxTotalSizeMb}
onChange={(e) => setLogsMaxTotalSizeMb(Number(e.target.value))}
disabled={disableControls || loading}
className={styles.retryInput}
/>
<Button
className={styles.retryButton}
onClick={handleLogsMaxTotalSizeUpdate}
loading={pending.logsMaxSize}
disabled={disableControls || loading}
>
{t('basic_settings.logs_max_total_size_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.routing_title')}>
<div className={`${styles.retryRow} ${styles.retryRowAligned} ${styles.retryRowInputGrow}`}>
<div className="form-group">
<label>{t('basic_settings.routing_strategy_label')}</label>
<select
className="input"
value={routingStrategy}
onChange={(e) => setRoutingStrategy(e.target.value)}
disabled={disableControls || loading}
>
<option value="round-robin">{t('basic_settings.routing_strategy_round_robin')}</option>
<option value="fill-first">{t('basic_settings.routing_strategy_fill_first')}</option>
</select>
<div className="hint">{t('basic_settings.routing_strategy_hint')}</div>
</div>
<Button
className={styles.retryButton}
onClick={handleRoutingStrategyUpdate}
loading={pending.routingStrategy}
disabled={disableControls || loading}
>
{t('basic_settings.routing_strategy_update')}
</Button>
</div>
</Card>
<Card title={t('basic_settings.quota_title')}> <Card title={t('basic_settings.quota_title')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<ToggleSwitch <ToggleSwitch

View File

@@ -4,6 +4,44 @@
import { apiClient } from './client'; import { apiClient } from './client';
import type { AuthFilesResponse } from '@/types/authFile'; import type { AuthFilesResponse } from '@/types/authFile';
import type { OAuthModelMappingEntry } from '@/types';
const normalizeOauthExcludedModels = (payload: unknown): Record<string, string[]> => {
if (!payload || typeof payload !== 'object') return {};
const source = (payload as any)['oauth-excluded-models'] ?? (payload as any).items ?? payload;
if (!source || typeof source !== 'object') return {};
const result: Record<string, string[]> = {};
Object.entries(source as Record<string, unknown>).forEach(([provider, models]) => {
const key = String(provider ?? '')
.trim()
.toLowerCase();
if (!key) return;
const rawList = Array.isArray(models)
? models
: typeof models === 'string'
? models.split(/[\n,]+/)
: [];
const seen = new Set<string>();
const normalized: string[] = [];
rawList.forEach((item) => {
const trimmed = String(item ?? '').trim();
if (!trimmed) return;
const modelKey = trimmed.toLowerCase();
if (seen.has(modelKey)) return;
seen.add(modelKey);
normalized.push(trimmed);
});
result[key] = normalized;
});
return result;
};
export const authFilesApi = { export const authFilesApi = {
list: () => apiClient.get<AuthFilesResponse>('/auth-files'), list: () => apiClient.get<AuthFilesResponse>('/auth-files'),
@@ -21,8 +59,7 @@ export const authFilesApi = {
// OAuth 排除模型 // OAuth 排除模型
async getOauthExcludedModels(): Promise<Record<string, string[]>> { async getOauthExcludedModels(): Promise<Record<string, string[]>> {
const data = await apiClient.get('/oauth-excluded-models'); const data = await apiClient.get('/oauth-excluded-models');
const payload = (data && (data['oauth-excluded-models'] ?? data.items ?? data)) as any; return normalizeOauthExcludedModels(data);
return payload && typeof payload === 'object' ? payload : {};
}, },
saveOauthExcludedModels: (provider: string, models: string[]) => saveOauthExcludedModels: (provider: string, models: string[]) =>
@@ -31,6 +68,40 @@ export const authFilesApi = {
deleteOauthExcludedEntry: (provider: string) => deleteOauthExcludedEntry: (provider: string) =>
apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`), apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`),
replaceOauthExcludedModels: (map: Record<string, string[]>) =>
apiClient.put('/oauth-excluded-models', normalizeOauthExcludedModels(map)),
// OAuth 模型映射
async getOauthModelMappings(): Promise<Record<string, OAuthModelMappingEntry[]>> {
const data = await apiClient.get('/oauth-model-mappings');
const payload = (data && (data['oauth-model-mappings'] ?? data.items ?? data)) as any;
if (!payload || typeof payload !== 'object') return {};
const result: Record<string, OAuthModelMappingEntry[]> = {};
Object.entries(payload).forEach(([channel, mappings]) => {
if (!Array.isArray(mappings)) return;
const normalized = mappings
.map((item) => {
if (!item || typeof item !== 'object') return null;
const name = String(item.name ?? item.id ?? item.model ?? '').trim();
const alias = String(item.alias ?? '').trim();
if (!name || !alias) return null;
const fork = item.fork === true;
return fork ? { name, alias, fork } : { name, alias };
})
.filter(Boolean) as OAuthModelMappingEntry[];
if (normalized.length) {
result[channel] = normalized;
}
});
return result;
},
saveOauthModelMappings: (channel: string, mappings: OAuthModelMappingEntry[]) =>
apiClient.patch('/oauth-model-mappings', { channel, mappings }),
deleteOauthModelMappings: (channel: string) =>
apiClient.delete(`/oauth-model-mappings?channel=${encodeURIComponent(channel)}`),
// 获取认证凭证支持的模型 // 获取认证凭证支持的模型
async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> { async getModelsForAuthFile(name: string): Promise<{ id: string; display_name?: string; type?: string; owned_by?: string }[]> {
const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`); const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`);

View File

@@ -68,8 +68,48 @@ export const configApi = {
*/ */
updateLoggingToFile: (enabled: boolean) => apiClient.put('/logging-to-file', { value: enabled }), updateLoggingToFile: (enabled: boolean) => apiClient.put('/logging-to-file', { value: enabled }),
/**
* 获取日志总大小上限MB
*/
async getLogsMaxTotalSizeMb(): Promise<number> {
const data = await apiClient.get('/logs-max-total-size-mb');
return data?.['logs-max-total-size-mb'] ?? data?.logsMaxTotalSizeMb ?? 0;
},
/**
* 更新日志总大小上限MB
*/
updateLogsMaxTotalSizeMb: (value: number) =>
apiClient.put('/logs-max-total-size-mb', { value }),
/** /**
* WebSocket 鉴权开关 * WebSocket 鉴权开关
*/ */
updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }), updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }),
/**
* 获取强制模型前缀开关
*/
async getForceModelPrefix(): Promise<boolean> {
const data = await apiClient.get('/force-model-prefix');
return data?.['force-model-prefix'] ?? data?.forceModelPrefix ?? false;
},
/**
* 更新强制模型前缀开关
*/
updateForceModelPrefix: (enabled: boolean) => apiClient.put('/force-model-prefix', { value: enabled }),
/**
* 获取路由策略
*/
async getRoutingStrategy(): Promise<string> {
const data = await apiClient.get('/routing/strategy');
return data?.strategy ?? data?.['routing-strategy'] ?? data?.routingStrategy ?? 'round-robin';
},
/**
* 更新路由策略
*/
updateRoutingStrategy: (strategy: string) => apiClient.put('/routing/strategy', { value: strategy }),
}; };

View File

@@ -61,6 +61,30 @@ const serializeProviderKey = (config: ProviderKeyConfig) => {
return payload; return payload;
}; };
const serializeVertexModelAliases = (models?: ModelAlias[]) =>
Array.isArray(models)
? models
.map((model) => {
const name = typeof model?.name === 'string' ? model.name.trim() : '';
const alias = typeof model?.alias === 'string' ? model.alias.trim() : '';
if (!name || !alias) return null;
return { name, alias };
})
.filter(Boolean)
: undefined;
const serializeVertexKey = (config: ProviderKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
const headers = serializeHeaders(config.headers);
if (headers) payload.headers = headers;
const models = serializeVertexModelAliases(config.models);
if (models && models.length) payload.models = models;
return payload;
};
const serializeGeminiKey = (config: GeminiKeyConfig) => { const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, any> = { 'api-key': config.apiKey }; const payload: Record<string, any> = { 'api-key': config.apiKey };
if (config.prefix?.trim()) payload.prefix = config.prefix.trim(); if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
@@ -140,6 +164,22 @@ export const providersApi = {
deleteClaudeConfig: (apiKey: string) => deleteClaudeConfig: (apiKey: string) =>
apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`), apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`),
async getVertexConfigs(): Promise<ProviderKeyConfig[]> {
const data = await apiClient.get('/vertex-api-key');
const list = (data && (data['vertex-api-key'] ?? data.items ?? data)) as any;
if (!Array.isArray(list)) return [];
return list.map((item) => normalizeProviderKeyConfig(item)).filter(Boolean) as ProviderKeyConfig[];
},
saveVertexConfigs: (configs: ProviderKeyConfig[]) =>
apiClient.put('/vertex-api-key', configs.map((item) => serializeVertexKey(item))),
updateVertexConfig: (index: number, value: ProviderKeyConfig) =>
apiClient.patch('/vertex-api-key', { index, value: serializeVertexKey(value) }),
deleteVertexConfig: (apiKey: string) =>
apiClient.delete(`/vertex-api-key?api-key=${encodeURIComponent(apiKey)}`),
async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> { async getOpenAIProviders(): Promise<OpenAIProviderConfig[]> {
const data = await apiClient.get('/openai-compatibility'); const data = await apiClient.get('/openai-compatibility');
const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any; const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any;

View File

@@ -258,7 +258,15 @@ export const normalizeConfigResponse = (raw: any): Config => {
config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled; config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled;
config.requestLog = raw['request-log'] ?? raw.requestLog; config.requestLog = raw['request-log'] ?? raw.requestLog;
config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile; config.loggingToFile = raw['logging-to-file'] ?? raw.loggingToFile;
config.logsMaxTotalSizeMb = raw['logs-max-total-size-mb'] ?? raw.logsMaxTotalSizeMb;
config.wsAuth = raw['ws-auth'] ?? raw.wsAuth; config.wsAuth = raw['ws-auth'] ?? raw.wsAuth;
config.forceModelPrefix = raw['force-model-prefix'] ?? raw.forceModelPrefix;
const routing = raw.routing;
if (routing && typeof routing === 'object') {
config.routingStrategy = routing.strategy ?? routing['strategy'];
} else {
config.routingStrategy = raw['routing-strategy'] ?? raw.routingStrategy;
}
config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys; config.apiKeys = Array.isArray(raw['api-keys']) ? raw['api-keys'].slice() : raw.apiKeys;
const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys; const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys;
@@ -282,6 +290,13 @@ export const normalizeConfigResponse = (raw: any): Config => {
.filter(Boolean) as ProviderKeyConfig[]; .filter(Boolean) as ProviderKeyConfig[];
} }
const vertexList = raw['vertex-api-key'] ?? raw.vertexApiKey ?? raw.vertexApiKeys;
if (Array.isArray(vertexList)) {
config.vertexApiKeys = vertexList
.map((item: any) => normalizeProviderKeyConfig(item))
.filter(Boolean) as ProviderKeyConfig[];
}
const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility; const openaiList = raw['openai-compatibility'] ?? raw.openaiCompatibility ?? raw.openAICompatibility;
if (Array.isArray(openaiList)) { if (Array.isArray(openaiList)) {
config.openaiCompatibility = openaiList config.openaiCompatibility = openaiList

View File

@@ -38,12 +38,16 @@ const SECTION_KEYS: RawConfigSection[] = [
'usage-statistics-enabled', 'usage-statistics-enabled',
'request-log', 'request-log',
'logging-to-file', 'logging-to-file',
'logs-max-total-size-mb',
'ws-auth', 'ws-auth',
'force-model-prefix',
'routing/strategy',
'api-keys', 'api-keys',
'ampcode', 'ampcode',
'gemini-api-key', 'gemini-api-key',
'codex-api-key', 'codex-api-key',
'claude-api-key', 'claude-api-key',
'vertex-api-key',
'openai-compatibility', 'openai-compatibility',
'oauth-excluded-models' 'oauth-excluded-models'
]; ];
@@ -65,8 +69,14 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
return config.requestLog; return config.requestLog;
case 'logging-to-file': case 'logging-to-file':
return config.loggingToFile; return config.loggingToFile;
case 'logs-max-total-size-mb':
return config.logsMaxTotalSizeMb;
case 'ws-auth': case 'ws-auth':
return config.wsAuth; return config.wsAuth;
case 'force-model-prefix':
return config.forceModelPrefix;
case 'routing/strategy':
return config.routingStrategy;
case 'api-keys': case 'api-keys':
return config.apiKeys; return config.apiKeys;
case 'ampcode': case 'ampcode':
@@ -77,6 +87,8 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
return config.codexApiKeys; return config.codexApiKeys;
case 'claude-api-key': case 'claude-api-key':
return config.claudeApiKeys; return config.claudeApiKeys;
case 'vertex-api-key':
return config.vertexApiKeys;
case 'openai-compatibility': case 'openai-compatibility':
return config.openaiCompatibility; return config.openaiCompatibility;
case 'oauth-excluded-models': case 'oauth-excluded-models':
@@ -194,9 +206,18 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
case 'logging-to-file': case 'logging-to-file':
nextConfig.loggingToFile = value; nextConfig.loggingToFile = value;
break; break;
case 'logs-max-total-size-mb':
nextConfig.logsMaxTotalSizeMb = value;
break;
case 'ws-auth': case 'ws-auth':
nextConfig.wsAuth = value; nextConfig.wsAuth = value;
break; break;
case 'force-model-prefix':
nextConfig.forceModelPrefix = value;
break;
case 'routing/strategy':
nextConfig.routingStrategy = value;
break;
case 'api-keys': case 'api-keys':
nextConfig.apiKeys = value; nextConfig.apiKeys = value;
break; break;
@@ -212,6 +233,9 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
case 'claude-api-key': case 'claude-api-key':
nextConfig.claudeApiKeys = value; nextConfig.claudeApiKeys = value;
break; break;
case 'vertex-api-key':
nextConfig.vertexApiKeys = value;
break;
case 'openai-compatibility': case 'openai-compatibility':
nextConfig.openaiCompatibility = value; nextConfig.openaiCompatibility = value;
break; break;

View File

@@ -19,12 +19,16 @@ export interface Config {
usageStatisticsEnabled?: boolean; usageStatisticsEnabled?: boolean;
requestLog?: boolean; requestLog?: boolean;
loggingToFile?: boolean; loggingToFile?: boolean;
logsMaxTotalSizeMb?: number;
wsAuth?: boolean; wsAuth?: boolean;
forceModelPrefix?: boolean;
routingStrategy?: string;
apiKeys?: string[]; apiKeys?: string[];
ampcode?: AmpcodeConfig; ampcode?: AmpcodeConfig;
geminiApiKeys?: GeminiKeyConfig[]; geminiApiKeys?: GeminiKeyConfig[];
codexApiKeys?: ProviderKeyConfig[]; codexApiKeys?: ProviderKeyConfig[];
claudeApiKeys?: ProviderKeyConfig[]; claudeApiKeys?: ProviderKeyConfig[];
vertexApiKeys?: ProviderKeyConfig[];
openaiCompatibility?: OpenAIProviderConfig[]; openaiCompatibility?: OpenAIProviderConfig[];
oauthExcludedModels?: Record<string, string[]>; oauthExcludedModels?: Record<string, string[]>;
raw?: Record<string, any>; raw?: Record<string, any>;
@@ -38,12 +42,16 @@ export type RawConfigSection =
| 'usage-statistics-enabled' | 'usage-statistics-enabled'
| 'request-log' | 'request-log'
| 'logging-to-file' | 'logging-to-file'
| 'logs-max-total-size-mb'
| 'ws-auth' | 'ws-auth'
| 'force-model-prefix'
| 'routing/strategy'
| 'api-keys' | 'api-keys'
| 'ampcode' | 'ampcode'
| 'gemini-api-key' | 'gemini-api-key'
| 'codex-api-key' | 'codex-api-key'
| 'claude-api-key' | 'claude-api-key'
| 'vertex-api-key'
| 'openai-compatibility' | 'openai-compatibility'
| 'oauth-excluded-models'; | 'oauth-excluded-models';

View File

@@ -33,3 +33,12 @@ export interface OAuthConfig {
export interface OAuthExcludedModels { export interface OAuthExcludedModels {
models: string[]; models: string[];
} }
// OAuth 模型映射
export interface OAuthModelMappingEntry {
name: string;
alias: string;
fork?: boolean;
}
export type OAuthModelMappings = Record<string, OAuthModelMappingEntry[]>;