diff --git a/src/components/providers/VertexSection/VertexModal.tsx b/src/components/providers/VertexSection/VertexModal.tsx new file mode 100644 index 00000000..bc609a83 --- /dev/null +++ b/src/components/providers/VertexSection/VertexModal.tsx @@ -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 { buildHeaderObject, headersToEntries } from '@/utils/headers'; +import type { ProviderModalProps, VertexFormState } from '../types'; + +interface VertexModalProps extends ProviderModalProps { + 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(buildEmptyForm); + + useEffect(() => { + if (!isOpen) return; + if (initialData) { + // eslint-disable-next-line react-hooks/set-state-in-effect + setForm({ + ...initialData, + headers: initialData.headers ?? {}, + modelEntries: modelsToEntries(initialData.models), + }); + return; + } + setForm(buildEmptyForm()); + }, [initialData, isOpen]); + + return ( + + + + + } + > + setForm((prev) => ({ ...prev, apiKey: e.target.value }))} + /> + setForm((prev) => ({ ...prev, prefix: e.target.value }))} + hint={t('ai_providers.prefix_hint')} + /> + setForm((prev) => ({ ...prev, baseUrl: e.target.value }))} + /> + setForm((prev) => ({ ...prev, proxyUrl: e.target.value }))} + /> + setForm((prev) => ({ ...prev, headers: buildHeaderObject(entries) }))} + addLabel={t('common.custom_headers_add')} + keyPlaceholder={t('common.custom_headers_key_placeholder')} + valuePlaceholder={t('common.custom_headers_value_placeholder')} + /> +
+ + 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} + /> +
{t('ai_providers.vertex_models_hint')}
+
+
+ ); +} diff --git a/src/components/providers/VertexSection/VertexSection.tsx b/src/components/providers/VertexSection/VertexSection.tsx new file mode 100644 index 00000000..8282aac7 --- /dev/null +++ b/src/components/providers/VertexSection/VertexSection.tsx @@ -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; +} + +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>(); + const allApiKeys = new Set(); + 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 ( + <> + + + {t('ai_providers.vertex_title')} + + } + extra={ + + } + > + + 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 ( + +
+ {t('ai_providers.vertex_item_title')} #{index + 1} +
+
+ {t('common.api_key')}: + {maskApiKey(item.apiKey)} +
+ {item.prefix && ( +
+ {t('common.prefix')}: + {item.prefix} +
+ )} + {item.baseUrl && ( +
+ {t('common.base_url')}: + {item.baseUrl} +
+ )} + {item.proxyUrl && ( +
+ {t('common.proxy_url')}: + {item.proxyUrl} +
+ )} + {headerEntries.length > 0 && ( +
+ {headerEntries.map(([key, value]) => ( + + {key}: {value} + + ))} +
+ )} + {item.models?.length ? ( +
+ + {t('ai_providers.vertex_models_count')}: {item.models.length} + + {item.models.map((model) => ( + + {model.name} + {model.alias && ( + {model.alias} + )} + + ))} +
+ ) : null} +
+ + {t('stats.success')}: {stats.success} + + + {t('stats.failure')}: {stats.failure} + +
+ +
+ ); + }} + /> +
+ + + + ); +} diff --git a/src/components/providers/VertexSection/index.ts b/src/components/providers/VertexSection/index.ts new file mode 100644 index 00000000..4a229a70 --- /dev/null +++ b/src/components/providers/VertexSection/index.ts @@ -0,0 +1 @@ +export { VertexSection } from './VertexSection'; diff --git a/src/components/providers/index.ts b/src/components/providers/index.ts index 7a96716e..c808aef3 100644 --- a/src/components/providers/index.ts +++ b/src/components/providers/index.ts @@ -3,6 +3,7 @@ export { ClaudeSection } from './ClaudeSection'; export { CodexSection } from './CodexSection'; export { GeminiSection } from './GeminiSection'; export { OpenAISection } from './OpenAISection'; +export { VertexSection } from './VertexSection'; export { ProviderList } from './ProviderList'; export { ProviderStatusBar } from './ProviderStatusBar'; export * from './hooks/useProviderStats'; diff --git a/src/components/providers/types.ts b/src/components/providers/types.ts index e4b0991a..825c51b5 100644 --- a/src/components/providers/types.ts +++ b/src/components/providers/types.ts @@ -6,6 +6,7 @@ export type ProviderModal = | { type: 'gemini'; index: number | null } | { type: 'codex'; index: number | null } | { type: 'claude'; index: number | null } + | { type: 'vertex'; index: number | null } | { type: 'ampcode'; index: null } | { type: 'openai'; index: number | null }; @@ -38,6 +39,10 @@ export type ProviderFormState = ProviderKeyConfig & { excludedText: string; }; +export type VertexFormState = Omit & { + modelEntries: ModelEntry[]; +}; + export interface ProviderSectionProps { configs: TConfig[]; keyStats: KeyStats; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7c1fc434..2eb89dfe 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -137,11 +137,22 @@ "usage_statistics_enable": "Enable usage statistics", "logging_title": "Logging", "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_enable": "Enable request logging", "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_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": { "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_add_btn": "Add Model", "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_modal_title": "Configure Ampcode", "ampcode_upstream_url_label": "Upstream URL", @@ -468,6 +500,34 @@ "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." }, + "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": { "codex_oauth_title": "Codex OAuth", "codex_oauth_button": "Start Codex Login", @@ -766,8 +826,11 @@ "quota_switch_preview_updated": "Preview model switch settings updated", "usage_statistics_updated": "Usage statistics 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", + "force_model_prefix_updated": "Model prefix setting updated", "ws_auth_updated": "WebSocket authentication setting updated", + "routing_strategy_updated": "Routing strategy updated", "login_storage_cleared": "Local login data cleared", "api_key_added": "API key added successfully", "api_key_updated": "API key updated successfully", @@ -786,6 +849,10 @@ "claude_config_added": "Claude configuration added successfully", "claude_config_updated": "Claude configuration updated 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_disabled": "Configuration disabled", "field_required": "Required fields cannot be empty", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index f5f286a1..7848504f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -137,11 +137,22 @@ "usage_statistics_enable": "启用使用统计", "logging_title": "日志记录", "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_enable": "启用请求日志", "request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。", + "force_model_prefix_enable": "强制模型前缀", "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": { "title": "API 密钥管理", @@ -221,6 +232,27 @@ "claude_models_hint": "为空表示使用全部模型;可填写 name[, alias] 以限制或重命名模型。", "claude_models_add_btn": "添加模型", "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_modal_title": "配置 Ampcode", "ampcode_upstream_url_label": "Upstream URL", @@ -468,6 +500,34 @@ "upgrade_required_title": "需要升级 CPA 版本", "upgrade_required_desc": "当前服务器版本不支持获取模型排除列表功能,请升级到最新版本的 CPA(CLI 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 模型映射功能,请升级到最新版本的 CPA(CLI Proxy API)后重试。" + }, "auth_login": { "codex_oauth_title": "Codex OAuth", "codex_oauth_button": "开始 Codex 登录", @@ -766,8 +826,11 @@ "quota_switch_preview_updated": "预览模型切换设置已更新", "usage_statistics_updated": "使用统计设置已更新", "logging_to_file_updated": "日志记录设置已更新", + "logs_max_total_size_updated": "日志容量设置已更新", "request_log_updated": "请求日志设置已更新", + "force_model_prefix_updated": "模型前缀设置已更新", "ws_auth_updated": "WebSocket 鉴权设置已更新", + "routing_strategy_updated": "路由策略已更新", "login_storage_cleared": "本地登录信息已清理", "api_key_added": "API密钥添加成功", "api_key_updated": "API密钥更新成功", @@ -786,6 +849,10 @@ "claude_config_added": "Claude配置添加成功", "claude_config_updated": "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_disabled": "配置已停用", "field_required": "必填字段不能为空", diff --git a/src/pages/AiProvidersPage.tsx b/src/pages/AiProvidersPage.tsx index e6b828aa..ee3388e7 100644 --- a/src/pages/AiProvidersPage.tsx +++ b/src/pages/AiProvidersPage.tsx @@ -7,11 +7,13 @@ import { CodexSection, GeminiSection, OpenAISection, + VertexSection, useProviderStats, type GeminiFormState, type OpenAIFormState, type ProviderFormState, type ProviderModal, + type VertexFormState, } from '@/components/providers'; import { parseExcludedModels, @@ -41,6 +43,7 @@ export function AiProvidersPage() { const [geminiKeys, setGeminiKeys] = useState([]); const [codexConfigs, setCodexConfigs] = useState([]); const [claudeConfigs, setClaudeConfigs] = useState([]); + const [vertexConfigs, setVertexConfigs] = useState([]); const [openaiProviders, setOpenaiProviders] = useState([]); const [saving, setSaving] = useState(false); @@ -63,17 +66,32 @@ export function AiProvidersPage() { setLoading(true); setError(''); 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 || []); setCodexConfigs(data?.codexApiKeys || []); setClaudeConfigs(data?.claudeApiKeys || []); + setVertexConfigs(data?.vertexApiKeys || []); setOpenaiProviders(data?.openaiCompatibility || []); - try { - const ampcode = await ampcodeApi.getAmpcode(); - updateConfigValue('ampcode', ampcode); + + if (vertexResult.status === 'fulfilled') { + setVertexConfigs(vertexResult.value || []); + updateConfigValue('vertex-api-key', vertexResult.value || []); + clearCache('vertex-api-key'); + } + + if (ampcodeResult.status === 'fulfilled') { + updateConfigValue('ampcode', ampcodeResult.value); clearCache('ampcode'); - } catch { - // ignore } } catch (err: unknown) { const message = getErrorMessage(err) || t('notification.refresh_failed'); @@ -92,11 +110,13 @@ export function AiProvidersPage() { if (config?.geminiApiKeys) setGeminiKeys(config.geminiApiKeys); if (config?.codexApiKeys) setCodexConfigs(config.codexApiKeys); if (config?.claudeApiKeys) setClaudeConfigs(config.claudeApiKeys); + if (config?.vertexApiKeys) setVertexConfigs(config.vertexApiKeys); if (config?.openaiCompatibility) setOpenaiProviders(config.openaiCompatibility); }, [ config?.geminiApiKeys, config?.codexApiKeys, config?.claudeApiKeys, + config?.vertexApiKeys, config?.openaiCompatibility, ]); @@ -112,6 +132,10 @@ export function AiProvidersPage() { setModal({ type, index }); }; + const openVertexModal = (index: number | null) => { + setModal({ type: 'vertex', index }); + }; + const openAmpcodeModal = () => { setModal({ type: 'ampcode', index: null }); }; @@ -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(headersToEntries(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) => { setSaving(true); try { @@ -412,6 +502,7 @@ export function AiProvidersPage() { const geminiModalIndex = modal?.type === 'gemini' ? modal.index : null; const codexModalIndex = modal?.type === 'codex' ? 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; return ( @@ -475,6 +566,23 @@ export function AiProvidersPage() { onSave={(form, editIndex) => saveProvider('claude', form, editIndex)} /> + openVertexModal(null)} + onEdit={(index) => openVertexModal(index)} + onDelete={deleteVertex} + onCloseModal={closeModal} + onSave={saveVertex} + /> + ({ + name: '', + alias: '', + fork: false +}); // 标准化 auth_index 值(与 usage.ts 中的 normalizeAuthIndex 保持一致) function normalizeAuthIndexValue(value: unknown): string | null { if (typeof value === 'number' && Number.isFinite(value)) { @@ -181,14 +193,25 @@ export function AuthFilesPage() { // OAuth 排除模型相关 const [excluded, setExcluded] = useState>({}); - const [excludedError, setExcludedError] = useState<'unsupported' | null>(null); + const [excludedError, setExcludedError] = useState<'unsupported' | null>(null); const [excludedModalOpen, setExcludedModalOpen] = useState(false); const [excludedForm, setExcludedForm] = useState({ provider: '', modelsText: '' }); const [savingExcluded, setSavingExcluded] = useState(false); + // OAuth 模型映射相关 + const [modelMappings, setModelMappings] = useState>({}); + const [modelMappingsError, setModelMappingsError] = useState<'unsupported' | null>(null); + const [mappingModalOpen, setMappingModalOpen] = useState(false); + const [mappingForm, setMappingForm] = useState({ + provider: '', + mappings: [buildEmptyMappingEntry()] + }); + const [savingMappings, setSavingMappings] = useState(false); + const fileInputRef = useRef(null); const loadingKeyStatsRef = useRef(false); const excludedUnsupportedRef = useRef(false); + const mappingsUnsupportedRef = useRef(false); const disableControls = connectionStatus !== 'connected'; @@ -252,7 +275,7 @@ export function AuthFilesPage() { const res = await authFilesApi.getOauthExcludedModels(); excludedUnsupportedRef.current = false; setExcluded(res || {}); - setExcludedError(null); + setExcludedError(null); } catch (err: unknown) { const status = typeof err === 'object' && err !== null && 'status' in err @@ -272,9 +295,35 @@ export function AuthFilesPage() { } }, [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 () => { - await Promise.all([loadFiles(), loadKeyStats(), loadExcluded()]); - }, [loadFiles, loadKeyStats, loadExcluded]); + await Promise.all([loadFiles(), loadKeyStats(), loadExcluded(), loadModelMappings()]); + }, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]); useHeaderRefresh(handleHeaderRefresh); @@ -282,7 +331,8 @@ export function AuthFilesPage() { loadFiles(); loadKeyStats(); loadExcluded(); - }, [loadFiles, loadKeyStats, loadExcluded]); + loadModelMappings(); + }, [loadFiles, loadKeyStats, loadExcluded, loadModelMappings]); // 定时刷新状态数据(每240秒) useInterval(loadKeyStats, 240_000); @@ -310,12 +360,26 @@ export function AuthFilesPage() { return lookup; }, [excluded]); + const mappingProviderLookup = useMemo(() => { + const lookup = new Map(); + 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 extraProviders = new Set(); Object.keys(excluded).forEach((provider) => { extraProviders.add(provider); }); + Object.keys(modelMappings).forEach((provider) => { + extraProviders.add(provider); + }); files.forEach((file) => { if (typeof file.type === 'string') { extraProviders.add(file.type); @@ -335,7 +399,7 @@ export function AuthFilesPage() { .sort((a, b) => a.localeCompare(b)); return [...OAUTH_PROVIDER_PRESETS, ...extraList]; - }, [excluded, files]); + }, [excluded, files, modelMappings]); // 过滤和搜索 const filtered = useMemo(() => { @@ -604,12 +668,12 @@ export function AuthFilesPage() { setExcludedModalOpen(true); }; - const saveExcludedModels = async () => { - const provider = excludedForm.provider.trim(); - if (!provider) { - showNotification(t('oauth_excluded.provider_required'), 'error'); - return; - } + const saveExcludedModels = async () => { + const provider = excludedForm.provider.trim(); + if (!provider) { + showNotification(t('oauth_excluded.provider_required'), 'error'); + return; + } const models = excludedForm.modelsText .split(/[\n,]+/) .map((item) => item.trim()) @@ -628,11 +692,11 @@ export function AuthFilesPage() { const errorMessage = err instanceof Error ? err.message : ''; showNotification(`${t('oauth_excluded.save_failed')}: ${errorMessage}`, 'error'); } finally { - setSavingExcluded(false); - } - }; - - const deleteExcluded = async (provider: string) => { + setSavingExcluded(false); + } + }; + + const deleteExcluded = async (provider: string) => { if (!window.confirm(t('oauth_excluded.delete_confirm', { provider }))) return; try { await authFilesApi.deleteOauthExcludedEntry(provider); @@ -641,8 +705,110 @@ export function AuthFilesPage() { } catch (err: unknown) { const errorMessage = 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(); + 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 = () => ( @@ -959,14 +1125,14 @@ export function AuthFilesPage() { title={t('oauth_excluded.title')} extra={ - } - > + size="sm" + onClick={() => openExcludedModal()} + disabled={disableControls || excludedError === 'unsupported'} + > + {t('oauth_excluded.add')} + + } + > {excludedError === 'unsupported' ? ( ))} - )} - - - {/* 详情弹窗 */} - + + {/* OAuth 模型映射卡片 */} + openMappingsModal()} + disabled={disableControls || modelMappingsError === 'unsupported'} + > + {t('oauth_model_mappings.add')} + + } + > + {modelMappingsError === 'unsupported' ? ( + + ) : Object.keys(modelMappings).length === 0 ? ( + + ) : ( +
+ {Object.entries(modelMappings).map(([provider, mappings]) => ( +
+
+
{provider}
+
+ {mappings?.length + ? t('oauth_model_mappings.model_count', { count: mappings.length }) + : t('oauth_model_mappings.no_models')} +
+
+
+ + +
+
+ ))} +
+ )} +
+ + {/* 详情弹窗 */} + setDetailModalOpen(false)} title={selectedFile?.name || t('auth_files.title_section')} footer={ @@ -1146,9 +1358,117 @@ export function AuthFilesPage() { value={excludedForm.modelsText} onChange={(e) => setExcludedForm((prev) => ({ ...prev, modelsText: e.target.value }))} /> -
{t('oauth_excluded.models_hint')}
- -
- - ); -} +
{t('oauth_excluded.models_hint')}
+ +
+ + {/* OAuth 模型映射弹窗 */} + setMappingModalOpen(false)} + title={t('oauth_model_mappings.add_title')} + footer={ + <> + + + + } + > +
+ setMappingForm((prev) => ({ ...prev, provider: e.target.value }))} + /> + + {providerOptions.map((provider) => ( + + {providerOptions.length > 0 && ( +
+ {providerOptions.map((provider) => { + const isActive = + mappingForm.provider.trim().toLowerCase() === provider.toLowerCase(); + return ( + + ); + })} +
+ )} +
+
+ +
+ {(mappingForm.mappings.length ? mappingForm.mappings : [buildEmptyMappingEntry()]).map( + (entry, index) => ( +
+ updateMappingEntry(index, 'name', e.target.value)} + disabled={savingMappings} + /> + → + updateMappingEntry(index, 'alias', e.target.value)} + disabled={savingMappings} + /> +
+ updateMappingEntry(index, 'fork', value)} + disabled={savingMappings} + /> +
+ +
+ ) + )} + +
+
{t('oauth_model_mappings.mappings_hint')}
+
+
+ + ); +} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index f83fa802..aadd4ef2 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -13,6 +13,9 @@ type PendingKey = | 'debug' | 'proxy' | 'retry' + | 'logsMaxSize' + | 'forceModelPrefix' + | 'routingStrategy' | 'switchProject' | 'switchPreview' | 'usage' @@ -31,6 +34,8 @@ export function SettingsPage() { const [loading, setLoading] = useState(true); const [proxyValue, setProxyValue] = useState(''); const [retryValue, setRetryValue] = useState(0); + const [logsMaxTotalSizeMb, setLogsMaxTotalSizeMb] = useState(0); + const [routingStrategy, setRoutingStrategy] = useState('round-robin'); const [pending, setPending] = useState>({} as Record); const [error, setError] = useState(''); @@ -41,9 +46,34 @@ export function SettingsPage() { setLoading(true); setError(''); 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 ?? ''); 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) { setError(err?.message || t('notification.refresh_failed')); } finally { @@ -52,7 +82,7 @@ export function SettingsPage() { }; load(); - }, [fetchConfig, t]); + }, [fetchConfig, t, updateConfigValue]); useEffect(() => { if (config) { @@ -60,8 +90,14 @@ export function SettingsPage() { if (typeof config.requestRetry === 'number') { 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) => { setPending((prev) => ({ ...prev, [key]: value })); @@ -69,7 +105,7 @@ export function SettingsPage() { const toggleSetting = async ( 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, updater: (val: boolean) => Promise, successMessage: string @@ -84,6 +120,8 @@ export function SettingsPage() { return config?.loggingToFile ?? false; case 'ws-auth': return config?.wsAuth ?? false; + case 'force-model-prefix': + return config?.forceModelPrefix ?? false; default: 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 quotaSwitchPreview = config?.quotaExceeded?.switchPreviewModel ?? false; @@ -171,63 +255,78 @@ export function SettingsPage() {
- {error &&
{error}
} -
- - toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated')) - } - /> + {error &&
{error}
} +
+ + toggleSetting('debug', 'debug', value, configApi.updateDebug, t('notification.debug_updated')) + } + /> - - toggleSetting( - 'usage', - 'usage-statistics-enabled', - value, - configApi.updateUsageStatistics, - t('notification.usage_statistics_updated') - ) - } - /> + + toggleSetting( + 'usage', + 'usage-statistics-enabled', + value, + configApi.updateUsageStatistics, + t('notification.usage_statistics_updated') + ) + } + /> - - toggleSetting( - 'loggingToFile', - 'logging-to-file', - value, - configApi.updateLoggingToFile, - t('notification.logging_to_file_updated') - ) - } - /> + + toggleSetting( + 'loggingToFile', + 'logging-to-file', + value, + configApi.updateLoggingToFile, + t('notification.logging_to_file_updated') + ) + } + /> - - toggleSetting( - 'wsAuth', - 'ws-auth', - value, - configApi.updateWsAuth, - t('notification.ws_auth_updated') - ) - } - /> -
- + + toggleSetting( + 'wsAuth', + 'ws-auth', + value, + configApi.updateWsAuth, + t('notification.ws_auth_updated') + ) + } + /> + + + toggleSetting( + 'forceModelPrefix', + 'force-model-prefix', + value, + configApi.updateForceModelPrefix, + t('notification.force_model_prefix_updated') + ) + } + /> +
+
+ +
+ setLogsMaxTotalSizeMb(Number(e.target.value))} + disabled={disableControls || loading} + className={styles.retryInput} + /> + +
+
+ + +
+
+ + +
{t('basic_settings.routing_strategy_hint')}
+
+ +
+
+
apiClient.get('/auth-files'), @@ -31,6 +32,37 @@ export const authFilesApi = { deleteOauthExcludedEntry: (provider: string) => apiClient.delete(`/oauth-excluded-models?provider=${encodeURIComponent(provider)}`), + // OAuth 模型映射 + async getOauthModelMappings(): Promise> { + 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 = {}; + 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 }[]> { const data = await apiClient.get(`/auth-files/models?name=${encodeURIComponent(name)}`); diff --git a/src/services/api/config.ts b/src/services/api/config.ts index 03f4c25b..84df1379 100644 --- a/src/services/api/config.ts +++ b/src/services/api/config.ts @@ -68,8 +68,48 @@ export const configApi = { */ updateLoggingToFile: (enabled: boolean) => apiClient.put('/logging-to-file', { value: enabled }), + /** + * 获取日志总大小上限(MB) + */ + async getLogsMaxTotalSizeMb(): Promise { + 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 鉴权开关 */ updateWsAuth: (enabled: boolean) => apiClient.put('/ws-auth', { value: enabled }), + + /** + * 获取强制模型前缀开关 + */ + async getForceModelPrefix(): Promise { + 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 { + 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 }), }; diff --git a/src/services/api/providers.ts b/src/services/api/providers.ts index d42537de..960852ea 100644 --- a/src/services/api/providers.ts +++ b/src/services/api/providers.ts @@ -61,6 +61,30 @@ const serializeProviderKey = (config: ProviderKeyConfig) => { 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 = { '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 payload: Record = { 'api-key': config.apiKey }; if (config.prefix?.trim()) payload.prefix = config.prefix.trim(); @@ -140,6 +164,22 @@ export const providersApi = { deleteClaudeConfig: (apiKey: string) => apiClient.delete(`/claude-api-key?api-key=${encodeURIComponent(apiKey)}`), + async getVertexConfigs(): Promise { + 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 { const data = await apiClient.get('/openai-compatibility'); const list = (data && (data['openai-compatibility'] ?? data.items ?? data)) as any; diff --git a/src/services/api/transformers.ts b/src/services/api/transformers.ts index 8b8814df..c99686c5 100644 --- a/src/services/api/transformers.ts +++ b/src/services/api/transformers.ts @@ -258,7 +258,15 @@ export const normalizeConfigResponse = (raw: any): Config => { config.usageStatisticsEnabled = raw['usage-statistics-enabled'] ?? raw.usageStatisticsEnabled; config.requestLog = raw['request-log'] ?? raw.requestLog; 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.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; const geminiList = raw['gemini-api-key'] ?? raw.geminiApiKey ?? raw.geminiApiKeys; @@ -282,6 +290,13 @@ export const normalizeConfigResponse = (raw: any): Config => { .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; if (Array.isArray(openaiList)) { config.openaiCompatibility = openaiList diff --git a/src/stores/useConfigStore.ts b/src/stores/useConfigStore.ts index 9e6c25ae..8df0d464 100644 --- a/src/stores/useConfigStore.ts +++ b/src/stores/useConfigStore.ts @@ -38,12 +38,16 @@ const SECTION_KEYS: RawConfigSection[] = [ 'usage-statistics-enabled', 'request-log', 'logging-to-file', + 'logs-max-total-size-mb', 'ws-auth', + 'force-model-prefix', + 'routing/strategy', 'api-keys', 'ampcode', 'gemini-api-key', 'codex-api-key', 'claude-api-key', + 'vertex-api-key', 'openai-compatibility', 'oauth-excluded-models' ]; @@ -65,8 +69,14 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection) return config.requestLog; case 'logging-to-file': return config.loggingToFile; + case 'logs-max-total-size-mb': + return config.logsMaxTotalSizeMb; case 'ws-auth': return config.wsAuth; + case 'force-model-prefix': + return config.forceModelPrefix; + case 'routing/strategy': + return config.routingStrategy; case 'api-keys': return config.apiKeys; case 'ampcode': @@ -77,6 +87,8 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection) return config.codexApiKeys; case 'claude-api-key': return config.claudeApiKeys; + case 'vertex-api-key': + return config.vertexApiKeys; case 'openai-compatibility': return config.openaiCompatibility; case 'oauth-excluded-models': @@ -194,9 +206,18 @@ export const useConfigStore = create((set, get) => ({ case 'logging-to-file': nextConfig.loggingToFile = value; break; + case 'logs-max-total-size-mb': + nextConfig.logsMaxTotalSizeMb = value; + break; case 'ws-auth': nextConfig.wsAuth = value; break; + case 'force-model-prefix': + nextConfig.forceModelPrefix = value; + break; + case 'routing/strategy': + nextConfig.routingStrategy = value; + break; case 'api-keys': nextConfig.apiKeys = value; break; @@ -212,6 +233,9 @@ export const useConfigStore = create((set, get) => ({ case 'claude-api-key': nextConfig.claudeApiKeys = value; break; + case 'vertex-api-key': + nextConfig.vertexApiKeys = value; + break; case 'openai-compatibility': nextConfig.openaiCompatibility = value; break; diff --git a/src/types/config.ts b/src/types/config.ts index 5272ec74..ddf29ef7 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -19,12 +19,16 @@ export interface Config { usageStatisticsEnabled?: boolean; requestLog?: boolean; loggingToFile?: boolean; + logsMaxTotalSizeMb?: number; wsAuth?: boolean; + forceModelPrefix?: boolean; + routingStrategy?: string; apiKeys?: string[]; ampcode?: AmpcodeConfig; geminiApiKeys?: GeminiKeyConfig[]; codexApiKeys?: ProviderKeyConfig[]; claudeApiKeys?: ProviderKeyConfig[]; + vertexApiKeys?: ProviderKeyConfig[]; openaiCompatibility?: OpenAIProviderConfig[]; oauthExcludedModels?: Record; raw?: Record; @@ -38,12 +42,16 @@ export type RawConfigSection = | 'usage-statistics-enabled' | 'request-log' | 'logging-to-file' + | 'logs-max-total-size-mb' | 'ws-auth' + | 'force-model-prefix' + | 'routing/strategy' | 'api-keys' | 'ampcode' | 'gemini-api-key' | 'codex-api-key' | 'claude-api-key' + | 'vertex-api-key' | 'openai-compatibility' | 'oauth-excluded-models'; diff --git a/src/types/oauth.ts b/src/types/oauth.ts index edcc95f9..4f36dcde 100644 --- a/src/types/oauth.ts +++ b/src/types/oauth.ts @@ -33,3 +33,12 @@ export interface OAuthConfig { export interface OAuthExcludedModels { models: string[]; } + +// OAuth 模型映射 +export interface OAuthModelMappingEntry { + name: string; + alias: string; + fork?: boolean; +} + +export type OAuthModelMappings = Record;