diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 62e9b2d..5fd9bc9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -9,67 +9,17 @@ Participation is governed by [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Project decisions and contribution licensing are described in [GOVERNANCE.md](../docs/project/GOVERNANCE.md). -## Development Setup +## Development And Verification -The CI baseline is Rust stable and Node.js 24. Install Rust with `rustfmt` and -`clippy`, Node.js/npm, `curl`, and a native compiler toolchain. Docker Compose is -needed for the complete stack. +Follow [Development](../docs/DEVELOPMENT.md) for the pinned toolchain, +PostgreSQL setup, and daily commands. Run `scripts/dev.sh check` before a pull +request; `scripts/dev.sh check --backend` is the Rust-only subset. -Confirm that the current Linux shell uses the pinned toolchain and does not -resolve Windows-mounted Node/npm binaries: - -```bash -scripts/doctor.sh --development -``` - -`scripts/install-deps-ubuntu.sh` installs only native helper packages; it does -not install Rust, Node.js, npm, Docker, or Playwright browsers. - -```bash -git clone git@github.com:tiammomo/ModelPort.git -cd ModelPort -cp .env.example .env -cp config.example.toml config.toml -# replace required placeholders; never commit this file -scripts/config-validate.sh -scripts/check.sh - -cd dashboard -npm ci -npm run lint -npm run build -``` - -Full setup and the change-to-test matrix are in -[docs/DEVELOPMENT.md](../docs/DEVELOPMENT.md). - -## Before A Pull Request - -Run the aggregate check: - -```bash -scripts/check-all.sh -``` - -If working incrementally, `scripts/check.sh` is the backend-only subset and -`cd dashboard && npm run check` is the dashboard subset. -Dependency updates must also pass `cargo audit --deny warnings --file Cargo.lock`, -`cargo deny check`, and `node scripts/audit-dashboard.mjs`; CI runs these -gates. Never add a broad or non-expiring audit exception. - -Then choose checks by risk: - -- dashboard behavior: affected Playwright specs or `npm run e2e`; -- auth, API keys, teams, quota, or backup: `scripts/acceptance.sh`; -- protocol, SSE, or Tool Use: relevant Rust tests and - `scripts/tool-use-acceptance.sh`; -- provider behavior: `scripts/provider-matrix.sh --model provider:model` and - real Tool Use acceptance when certification is intended; -- Docker/systemd/reverse proxy: build/install the deployment and run smoke - through the deployed origin. - -Real upstream checks can incur cost and must use your own local secrets. CI and -ordinary pull requests should prefer mock-backed checks. +Use the [change-to-test matrix](../docs/DEVELOPMENT.md#change-to-test-matrix) +for additional checks. Dependency changes must also pass the +[dependency audits](../docs/DEVELOPMENT.md#dependency-audits). Never add a broad +or non-expiring audit exception. Ordinary verification uses synthetic data; +real Provider certification requires explicitly intended, potentially paid calls. ## Code And Security Conventions diff --git a/README.md b/README.md index 642c677..e163cfb 100644 --- a/README.md +++ b/README.md @@ -89,28 +89,10 @@ project provides no paid edition, hosted service, or community-support SLA. ## Development -The source-development path requires a reachable PostgreSQL instance; the -development scripts do not start one. See [Development](docs/DEVELOPMENT.md) -for a loopback-only disposable database command and the complete prerequisites. -The [repository map](docs/DEVELOPMENT.md#repository-map) explains where code, -resources, tests, and project policies belong. - -```bash -cp .env.example .env -cp config.example.toml config.toml -# replace required placeholders -scripts/start.sh - -cd dashboard -npm ci -npm run dev -``` - -Before submitting a change: - -```bash -scripts/check-all.sh -``` +Use the [development guide](docs/DEVELOPMENT.md) for the repository map, +pinned tools, PostgreSQL setup, and frontend workflow. Native gateway commands +share `scripts/dev.sh`; use `scripts/dev.sh help` to see the available actions. +Before submitting a change, run `scripts/dev.sh check`. ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index f44de67..a2781ca 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -80,25 +80,9 @@ CIDR、安全 Cookie、CSRF 防护和控制台 API Key。不要提交 `.env`、P ## 本地开发 -参见[开发指南与目录职责](docs/DEVELOPMENT.md#repository-map)。本地开发需要 -可连接的 PostgreSQL;开发脚本不会自动启动数据库。 - -```bash -cp .env.example .env -cp config.example.toml config.toml -# 替换必填 placeholder -scripts/start.sh - -cd dashboard -npm ci -npm run dev -``` - -提交变更前: - -```bash -scripts/check-all.sh -``` +[开发指南](docs/DEVELOPMENT.md)统一说明目录职责、工具版本、PostgreSQL 准备和 +前端开发流程。本地网关统一使用 `scripts/dev.sh`,运行 `scripts/dev.sh help` +查看启动、停止、状态与排障命令;提交前运行 `scripts/dev.sh check`。 ## 许可证 diff --git a/dashboard/e2e/provider-management.spec.ts b/dashboard/e2e/provider-management.spec.ts index b4fbb34..900f222 100644 --- a/dashboard/e2e/provider-management.spec.ts +++ b/dashboard/e2e/provider-management.spec.ts @@ -55,7 +55,7 @@ test.describe('provider management', () => { await expect(card.getByText('已禁用')).toHaveCount(0) }) - test('exposes credential pool controls on provider cards', async ({ page }) => { + test('validates, creates, edits and deletes credentials on provider cards', async ({ page }) => { const suffix = Date.now().toString(36) const providerId = 'deepseek' const credentialId = `e2e_pool_${suffix}` @@ -70,6 +70,9 @@ test.describe('provider management', () => { try { await card.getByRole('button', { name: '新增' }).click() const credentialDialog = page.getByRole('dialog') + await credentialDialog.getByRole('button', { name: '新增账号' }).click() + await expect(credentialDialog.locator('#credential-id')).toHaveAttribute('aria-invalid', 'true') + await expect(credentialDialog.locator('#credential-id')).toBeFocused() await credentialDialog.getByPlaceholder('例如: account-a').fill(credentialId) await credentialDialog.getByPlaceholder('例如: Mimo 主账号').fill('Pool Account A') await credentialDialog.getByPlaceholder('例如: MIMO_OPENAI_API_KEY_ALT').fill(`E2E_POOL_KEY_${suffix.toUpperCase()}`) @@ -83,6 +86,20 @@ test.describe('provider management', () => { await card.getByRole('combobox').first().click() await page.getByRole('option', { name: '轮询' }).click() await expect(card).toContainText('轮询') + + await card.getByRole('button', { name: '编辑上游账号 Pool Account A' }).click() + const editDialog = page.getByRole('dialog') + await expect(editDialog.locator('#credential-id')).toHaveCount(0) + await editDialog.locator('#credential-name').fill('Pool Account Updated') + await editDialog.getByRole('button', { name: '保存账号' }).click() + await expect(editDialog).not.toBeVisible() + await expect(card).toContainText('Pool Account Updated') + + await card.getByRole('button', { name: '删除上游账号 Pool Account Updated' }).click() + await page.getByRole('dialog').getByRole('button', { name: '删除账号' }).click() + await expect(page.getByRole('dialog')).not.toBeVisible() + await expect(card).not.toContainText('Pool Account Updated') + await expect(card).toContainText('默认凭证') } finally { await page.request.delete( `/admin/providers/${providerId}/credentials/${encodeURIComponent(credentialId)}`, diff --git a/dashboard/src/features/models/ModelFormField.tsx b/dashboard/src/features/models/ModelFormField.tsx index c6ab215..6cb0b58 100644 --- a/dashboard/src/features/models/ModelFormField.tsx +++ b/dashboard/src/features/models/ModelFormField.tsx @@ -1,3 +1,4 @@ +import { Switch } from '@/components/ui/switch' import type { ReactNode } from 'react' import { CircleAlert } from 'lucide-react' import { Label } from '@/components/ui/label' @@ -39,3 +40,21 @@ export function Field({ ) } +export function SwitchRow({ + label, + checked, + disabled, + onCheckedChange, +}: { + label: string + checked: boolean + disabled?: boolean + onCheckedChange: (checked: boolean) => void +}) { + return ( +
+ + +
+ ) +} diff --git a/dashboard/src/features/models/ProviderCredentials.tsx b/dashboard/src/features/models/ProviderCredentials.tsx new file mode 100644 index 0000000..e0aee4b --- /dev/null +++ b/dashboard/src/features/models/ProviderCredentials.tsx @@ -0,0 +1,426 @@ +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { + useCreateProviderCredential, + useDeleteProviderCredential, + useSelectProviderCredential, + useUpdateProviderCredential, + useUpdateProviderCredentialPoolMode, +} from '@/hooks' +import { focusFirstInvalidDialogField, formatNumber, formatRelativeTime } from '@/lib/utils' +import type { Provider, ProviderCredential, ProviderCredentialPoolMode, ProviderOnlineBalance } from '@/types' +import { AlertTriangle, Loader2, Pencil, Plus, Trash2, WalletCards } from 'lucide-react' +import { useMemo, useState } from 'react' +import { toast } from 'sonner' +import { + CREDENTIAL_POOL_MODE_LABELS, + DEFAULT_CREDENTIAL_FORM, + credentialPayloadFromForm, + credentialToForm, + providerCredentialState, + providerDisplayTitle, + type ProviderCredentialFormState, +} from './model-data' +import { Field, SwitchRow } from './ModelFormField' +import { validateCredentialForm } from './operator-state' + +export function ProviderCredentials({ provider, canManage, onlineBalance, checkingBalance, onCheckBalance }: { + provider: Provider + canManage: boolean + onlineBalance?: ProviderOnlineBalance + checkingBalance: boolean + onCheckBalance: () => void +}) { + const createProviderCredential = useCreateProviderCredential() + const updateProviderCredential = useUpdateProviderCredential() + const selectProviderCredential = useSelectProviderCredential() + const updateProviderCredentialPoolMode = useUpdateProviderCredentialPoolMode() + const deleteProviderCredential = useDeleteProviderCredential() + const [credentialSubmitAttempted, setCredentialSubmitAttempted] = useState(false) + const [credentialDialogOpen, setCredentialDialogOpen] = useState(false) + const [editingCredential, setEditingCredential] = useState(null) + const [credentialForm, setCredentialForm] = useState(DEFAULT_CREDENTIAL_FORM) + const [credentialDeleteTarget, setCredentialDeleteTarget] = useState(null) + + const { credentials, activeCredential, credentialReady, credentialPoolMode } = providerCredentialState(provider) + const displayTitle = providerDisplayTitle(provider) + const credentialBusy = selectProviderCredential.isPending || updateProviderCredentialPoolMode.isPending || deleteProviderCredential.isPending + const credentialValidation = useMemo( + () => validateCredentialForm(credentialForm, !editingCredential), + [credentialForm, editingCredential], + ) + const openCredentialDialog = (credential?: ProviderCredential) => { + setCredentialDialogOpen(true) + setEditingCredential(credential ?? null) + setCredentialForm(credentialToForm(provider, credential)) + setCredentialSubmitAttempted(false) + } + + const closeCredentialDialog = () => { + setCredentialDialogOpen(false) + setEditingCredential(null) + setCredentialForm(DEFAULT_CREDENTIAL_FORM) + setCredentialSubmitAttempted(false) + } + + const handleSubmitCredential = () => { + if (!credentialDialogOpen) return + setCredentialSubmitAttempted(true) + if (!credentialValidation.valid) { + toast.error('请先修正账号表单中的错误') + focusFirstInvalidDialogField() + return + } + const data = credentialPayloadFromForm(credentialForm, !editingCredential) + const options = { + onSuccess: () => { + toast.success(editingCredential + ? '账号引用已更新;如环境变量值有变化,请重启进程并重新测试' + : '账号引用已新增;注入环境变量、重启进程并重新测试后才会生效') + closeCredentialDialog() + }, + onError: (error: unknown) => toast.error(error instanceof Error ? error.message : '保存账号失败'), + } + + if (editingCredential) { + updateProviderCredential.mutate({ + providerId: provider.id, + credentialId: editingCredential.id, + data, + }, options) + } else { + createProviderCredential.mutate({ + providerId: provider.id, + data, + }, options) + } + } + + const handleSelectProviderCredential = (credentialId: string) => { + selectProviderCredential.mutate({ providerId: provider.id, credentialId }, { + onSuccess: () => toast.success(`已切换 ${provider.displayName} 账号`), + onError: (error) => toast.error(error instanceof Error ? error.message : '切换账号失败'), + }) + } + + const handleUpdateProviderCredentialPoolMode = (mode: ProviderCredentialPoolMode) => { + updateProviderCredentialPoolMode.mutate({ providerId: provider.id, mode }, { + onSuccess: () => toast.success(`已更新 ${provider.displayName} 号池策略`), + onError: (error) => toast.error(error instanceof Error ? error.message : '更新号池策略失败'), + }) + } + + const handleDeleteProviderCredential = () => { + if (!credentialDeleteTarget) return + const credential = credentialDeleteTarget + deleteProviderCredential.mutate({ providerId: provider.id, credentialId: credential.id }, { + onSuccess: () => { + toast.success(`已删除账号 ${credential.name}`) + setCredentialDeleteTarget(null) + }, + onError: (error) => toast.error(error instanceof Error ? error.message : '删除账号失败'), + }) + } + + return ( + <> +
+
+
+

上游账号

+

+ {credentials.length > 0 ? `${credentials.length} 个账号 · ${CREDENTIAL_POOL_MODE_LABELS[credentialPoolMode]}` : '默认凭证'} +

+
+
+ + {canManage && } +
+
+ {provider.id === 'deepseek' && canManage && ( +
+
+
+
+

DeepSeek 线上余额

+ {onlineBalance && ( + + {onlineBalance.isAvailable ? '可调用' : '余额不足'} + + )} +
+

+ 实时只读查询;充值、退款与账单以 DeepSeek 控制台为准。 +

+
+ +
+ {onlineBalance && ( +
+ {onlineBalance.balanceInfos.map((balance) => ( +
+

{balance.currency} 可用总额

+

+ {balance.totalBalance} {balance.currency} +

+

+ 赠金 {balance.grantedBalance} · 充值 {balance.toppedUpBalance} +

+
+ ))} +

+ 最近查询:{formatRelativeTime(onlineBalance.checkedAt)} +

+
+ )} +
+ )} + {credentials.length === 0 ? ( +
+ + {credentialReady ? '默认环境变量可用' : '缺少默认密钥'} + + {provider.apiKeyEnv || '无需 API Key'} +
+ ) : ( +
+ +
+ {activeCredential && ( + <> + + {activeCredential.hasApiKey ? 'Key 可用' : 'Key 缺失'} + + {canManage && } + {canManage && } + + )} +
+ {activeCredential && ( +
+

+ 环境变量:{activeCredential.apiKeyEnv} +

+ {activeCredential.baseUrl && ( +

+ Base URL:{activeCredential.baseUrl} +

+ )} +
+ )} +
+ {credentials.map((credential) => { + const health = credential.health + const healthStatus = health?.status ?? (credential.hasApiKey ? 'healthy' : 'degraded') + const credentialRechargeBadge = health?.rechargeRequired ? '等待充值' : null + return ( +
+
+
+ {credential.name} + {credential.active && 当前} + {credential.status === 'disabled' && 禁用} +
+
+ {credential.apiKeyEnv} + {health?.lastUsedAt && 最近 {formatRelativeTime(health.lastUsedAt)}} +
+
+
+ + {credential.hasApiKey ? 'Key 可用' : 'Key 缺失'} + + + {credentialHealthLabel(healthStatus)} + + {credentialRechargeBadge && {credentialRechargeBadge}} + + {health?.requestsTotal ? `${formatNumber(health.requestsTotal)} 次 · ${Math.round(health.successRate)}%` : '暂无请求'} + +
+ {health?.lastError && ( +

{health.lastError}

+ )} +
+ ) + })} +
+
+ )} +
+ + { if (!open) closeCredentialDialog() }}> + + + {editingCredential ? '编辑上游账号' : '新增上游账号'} + + 账号只保存环境变量名;真实 API Key 仍放在 .env、容器 Secret 或系统环境变量中。保存后需重启并重新运行 Provider 连接测试。 + + +
+ {!editingCredential && ( + + setCredentialForm({ ...credentialForm, id: event.target.value.toLowerCase() })} + placeholder="例如: account-a" + aria-invalid={credentialSubmitAttempted && Boolean(credentialValidation.errors.id)} + aria-required="true" + /> + + )} + + setCredentialForm({ ...credentialForm, name: event.target.value })} + placeholder="例如: Mimo 主账号" + aria-invalid={credentialSubmitAttempted && Boolean(credentialValidation.errors.name)} + aria-required="true" + /> + + + setCredentialForm({ ...credentialForm, apiKeyEnv: event.target.value })} + placeholder="例如: MIMO_OPENAI_API_KEY_ALT" + aria-invalid={credentialSubmitAttempted && Boolean(credentialValidation.errors.apiKeyEnv)} + aria-required="true" + /> + + + setCredentialForm({ ...credentialForm, baseUrl: event.target.value })} + placeholder="可选,不填则沿用供应商 Base URL" + aria-invalid={credentialSubmitAttempted && Boolean(credentialValidation.errors.baseUrl)} + /> + +
+ setCredentialForm({ ...credentialForm, status: checked ? 'active' : 'disabled' })} + /> +
+ {credentialValidation.warnings.length > 0 && ( +
+ + {credentialValidation.warnings.join(' ')} +
+ )} +
+ + + + +
+
+ + { if (!open) setCredentialDeleteTarget(null) }}> + + + 删除上游账号 + 账号配置和健康记录会删除;真实环境变量不会被修改。 + +
+

{credentialDeleteTarget?.name}

+

{credentialDeleteTarget?.apiKeyEnv}

+ {credentialDeleteTarget?.active && ( +

这是当前账号;删除后系统会选择其他可用账号,若没有候选则 Provider 可能不可路由。

+ )} +
+ + + + +
+
+ + ) +} + +function credentialHealthLabel(status: string) { + if (status === 'cooldown') return '冷却' + if (status === 'degraded') return '降级' + return '健康' +} + +function credentialHealthVariant(status: string): 'success' | 'warning' { + if (status === 'cooldown' || status === 'degraded') return 'warning' + return 'success' +} + diff --git a/dashboard/src/features/models/model-data.test.ts b/dashboard/src/features/models/model-data.test.ts index 3dcffd7..e581c82 100644 --- a/dashboard/src/features/models/model-data.test.ts +++ b/dashboard/src/features/models/model-data.test.ts @@ -5,6 +5,7 @@ import { defaultToolStreamingArguments, parseList, providerInventoryItems, + providerCredentialState, providerOrigin, providerPayloadFromForm, providerToForm, @@ -128,3 +129,25 @@ describe('model feature data', () => { expect(dependencyLabel('apiKey')).toBe('API 密钥') }) }) + +describe('provider credential availability', () => { + const credential = { id: 'pool-a', providerId: 'openai', name: 'Account A', apiKeyEnv: 'POOL_A_KEY', status: 'active' as const, active: false, hasApiKey: true } + + it('recognizes a resolved pool credential when the default environment key is missing', () => { + const source = provider({ hasApiKey: false, credentials: [credential], activeCredentialId: credential.id }) + expect(providerCredentialState(source).credentialReady).toBe(true) + expect(providerCredentialState(source).activeCredential?.id).toBe(credential.id) + }) + + it('ignores disabled or unresolved pool credentials for readiness', () => { + expect(providerCredentialState(provider({ hasApiKey: false, credentials: [{ ...credential, status: 'disabled' }] })).credentialReady).toBe(false) + expect(providerCredentialState(provider({ hasApiKey: false, credentials: [{ ...credential, hasApiKey: false }] })).credentialReady).toBe(false) + expect(providerCredentialState(provider({ hasApiKey: false, apiKeyRequired: false })).credentialReady).toBe(true) + }) + + it('keeps the runtime active credential ahead of the configured fallback', () => { + const active = { ...credential, id: 'pool-b', active: true } + const source = provider({ credentials: [credential, active], activeCredentialId: credential.id }) + expect(providerCredentialState(source).activeCredential?.id).toBe(active.id) + }) +}) diff --git a/dashboard/src/features/models/model-data.ts b/dashboard/src/features/models/model-data.ts index 3d1348e..e4d4d82 100644 --- a/dashboard/src/features/models/model-data.ts +++ b/dashboard/src/features/models/model-data.ts @@ -506,3 +506,16 @@ export function dependencyLabel(type: string): string { if (type === 'route') return '路由配置' return type } + +export function providerCredentialState(provider: Provider) { + const credentials = provider.credentials ?? [] + return { + credentials, + credentialReady: provider.hasApiKey || !provider.apiKeyRequired + || credentials.some((credential) => credential.status === 'active' && credential.hasApiKey), + activeCredential: credentials.find((credential) => credential.active) + ?? credentials.find((credential) => credential.id === provider.activeCredentialId) + ?? null, + credentialPoolMode: provider.credentialPoolMode ?? 'failover', + } +} diff --git a/dashboard/src/features/models/operator-state.ts b/dashboard/src/features/models/operator-state.ts index bbdd157..1f39963 100644 --- a/dashboard/src/features/models/operator-state.ts +++ b/dashboard/src/features/models/operator-state.ts @@ -1,5 +1,5 @@ import type { Provider } from '@/types' -import { parseList, type ProviderCredentialFormState, type ProviderFormState } from './model-data' +import { parseList, providerCredentialState, type ProviderCredentialFormState, type ProviderFormState } from './model-data' export type ProviderReadinessLevel = 'ready' | 'attention' | 'blocked' | 'disabled' @@ -32,9 +32,7 @@ export function providerReadiness(provider: Provider, isDefault = false): Provid } } - const credentialReady = provider.hasApiKey - || !provider.apiKeyRequired - || Boolean(provider.credentials?.some((credential) => credential.status === 'active' && credential.hasApiKey)) + const { credentialReady } = providerCredentialState(provider) if (!credentialReady) { return { level: 'blocked', diff --git a/dashboard/src/lib/utils.ts b/dashboard/src/lib/utils.ts index 29788a7..e0f0f6c 100644 --- a/dashboard/src/lib/utils.ts +++ b/dashboard/src/lib/utils.ts @@ -106,3 +106,9 @@ export function debounce unknown>( timer = setTimeout(() => fn(...args), ms) } } + +export function focusFirstInvalidDialogField() { + window.requestAnimationFrame(() => { + document.querySelector('[role="dialog"] [aria-invalid="true"]')?.focus() + }) +} diff --git a/dashboard/src/pages/ModelsPage.tsx b/dashboard/src/pages/ModelsPage.tsx index 58e969d..8b95485 100644 --- a/dashboard/src/pages/ModelsPage.tsx +++ b/dashboard/src/pages/ModelsPage.tsx @@ -18,18 +18,15 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { apiKeyExpiryState } from '@/features/api-keys/api-key-view' import { - CREDENTIAL_POOL_MODE_LABELS, - DEFAULT_CREDENTIAL_FORM, DEFAULT_PROVIDER_FORM, PROVIDER_OPERATIONAL_FILTERS, - credentialPayloadFromForm, - credentialToForm, defaultToolStreamingArguments, defaultToolUseForProviderForm, dependencyLabel, modelRouteTitle, providerDeleteBlockedFromError, providerDisplayTitle, + providerCredentialState, providerFilterCount, providerIdentity, providerInventoryGroups, @@ -41,17 +38,16 @@ import { providerPayloadFromForm, providerRuntimeState, providerToForm, - type ProviderCredentialFormState, type ProviderFormState, type ProviderInventoryGroup, type ProviderOperationalFilter, } from '@/features/models/model-data' import { ModelAdaptationDialog } from '@/features/models/ModelAdaptationDialog' -import { Field } from '@/features/models/ModelFormField' +import { Field, SwitchRow } from '@/features/models/ModelFormField' +import { ProviderCredentials } from '@/features/models/ProviderCredentials' import { providerReadiness, validateAliasForm, - validateCredentialForm, validateProviderForm, type ProviderReadinessLevel, } from '@/features/models/operator-state' @@ -63,22 +59,17 @@ import { useCheckProviderBalance, useCreateAlias, useCreateProvider, - useCreateProviderCredential, useDeleteAlias, useDeleteProvider, - useDeleteProviderCredential, useDiscoverProviderModels, useNow, useProviders, - useSelectProviderCredential, useSetProviderDisabled, useSettings, useToggleModel, useUpdateDefaultModel, useUpdateDefaultProvider, useUpdateProvider, - useUpdateProviderCredential, - useUpdateProviderCredentialPoolMode, useUpdateProviderOrder, } from '@/hooks' import { PROVIDER_PROTOCOL_LABELS } from '@/lib/constants' @@ -91,14 +82,12 @@ import { type ProviderTemplate, } from '@/lib/model-catalog' import { paginateItems } from '@/lib/pagination' -import { cn, copyToClipboard, formatNumber, formatRelativeTime } from '@/lib/utils' +import { cn, copyToClipboard, focusFirstInvalidDialogField, formatNumber, formatRelativeTime } from '@/lib/utils' import { useAuthStore } from '@/stores' import type { FidelityMode, MaxTokensField, Provider, -ProviderCredential, -ProviderCredentialPoolMode, ProviderDeleteBlocked, ProviderModelInventory, ProviderOnlineBalance, @@ -129,7 +118,6 @@ import { Search, Settings, Trash2, - WalletCards, } from 'lucide-react' import { Fragment, useMemo, useState } from 'react' import { Link } from 'react-router-dom' @@ -199,11 +187,6 @@ export function ModelsPage() { const createProvider = useCreateProvider() const updateProvider = useUpdateProvider() const setProviderDisabled = useSetProviderDisabled() - const createProviderCredential = useCreateProviderCredential() - const updateProviderCredential = useUpdateProviderCredential() - const selectProviderCredential = useSelectProviderCredential() - const updateProviderCredentialPoolMode = useUpdateProviderCredentialPoolMode() - const deleteProviderCredential = useDeleteProviderCredential() const deleteProvider = useDeleteProvider() const toggleModel = useToggleModel() const bulkToggleModels = useBulkToggleModels() @@ -220,17 +203,13 @@ export function ModelsPage() { const [showProviderDialog, setShowProviderDialog] = useState(false) const [aliasSubmitAttempted, setAliasSubmitAttempted] = useState(false) const [providerSubmitAttempted, setProviderSubmitAttempted] = useState(false) - const [credentialSubmitAttempted, setCredentialSubmitAttempted] = useState(false) - const [credentialDialogProvider, setCredentialDialogProvider] = useState(null) const [selectedTemplate, setSelectedTemplate] = useState(null) const [editingProvider, setEditingProvider] = useState(null) - const [editingCredential, setEditingCredential] = useState(null) const [editingModelAdaptation, setEditingModelAdaptation] = useState<{ provider: Provider item: ProviderModelInventory } | null>(null) const [providerForm, setProviderForm] = useState(DEFAULT_PROVIDER_FORM) - const [credentialForm, setCredentialForm] = useState(DEFAULT_CREDENTIAL_FORM) const [deleteTarget, setDeleteTarget] = useState(null) const [deleteBlock, setDeleteBlock] = useState(null) const [deleteConfirmation, setDeleteConfirmation] = useState('') @@ -244,11 +223,6 @@ export function ModelsPage() { const [aliasPageSize, setAliasPageSize] = useState(20) const [activeTab, setActiveTab] = useState('library') const [aliasDeleteTarget, setAliasDeleteTarget] = useState(null) - const [credentialDeleteTarget, setCredentialDeleteTarget] = useState<{ - provider: Provider - credential: ProviderCredential - } | null>(null) - const configuredProviderIds = useMemo(() => new Set(providers.map((provider) => provider.id)), [providers]) const defaultProvider = settings?.gateway.defaultProvider.trim() ?? '' const providerOrder = useMemo( @@ -354,10 +328,6 @@ export function ModelsPage() { } : null const providerValidation = useMemo(() => validateProviderForm(providerForm), [providerForm]) - const credentialValidation = useMemo( - () => validateCredentialForm(credentialForm, !editingCredential), - [credentialForm, editingCredential], - ) const aliasValidation = useMemo( () => validateAliasForm(aliasForm.alias, aliasForm.target), [aliasForm.alias, aliasForm.target], @@ -413,20 +383,6 @@ export function ModelsPage() { setProviderSubmitAttempted(false) } - const openCredentialDialog = (provider: Provider, credential?: ProviderCredential) => { - setCredentialDialogProvider(provider) - setEditingCredential(credential ?? null) - setCredentialForm(credentialToForm(provider, credential)) - setCredentialSubmitAttempted(false) - } - - const closeCredentialDialog = () => { - setCredentialDialogProvider(null) - setEditingCredential(null) - setCredentialForm(DEFAULT_CREDENTIAL_FORM) - setCredentialSubmitAttempted(false) - } - const handleSubmitProvider = () => { setProviderSubmitAttempted(true) if (!providerValidation.valid) { @@ -452,39 +408,6 @@ export function ModelsPage() { } } - const handleSubmitCredential = () => { - if (!credentialDialogProvider) return - setCredentialSubmitAttempted(true) - if (!credentialValidation.valid) { - toast.error('请先修正账号表单中的错误') - focusFirstInvalidDialogField() - return - } - const data = credentialPayloadFromForm(credentialForm, !editingCredential) - const options = { - onSuccess: () => { - toast.success(editingCredential - ? '账号引用已更新;如环境变量值有变化,请重启进程并重新测试' - : '账号引用已新增;注入环境变量、重启进程并重新测试后才会生效') - closeCredentialDialog() - }, - onError: (error: unknown) => toast.error(error instanceof Error ? error.message : '保存账号失败'), - } - - if (editingCredential) { - updateProviderCredential.mutate({ - providerId: credentialDialogProvider.id, - credentialId: editingCredential.id, - data, - }, options) - } else { - createProviderCredential.mutate({ - providerId: credentialDialogProvider.id, - data, - }, options) - } - } - const handleSetProviderDisabled = (provider: Provider) => { const disabled = provider.status !== 'disabled' setProviderDisabled.mutate({ providerId: provider.id, disabled }, { @@ -493,32 +416,6 @@ export function ModelsPage() { }) } - const handleSelectProviderCredential = (provider: Provider, credentialId: string) => { - selectProviderCredential.mutate({ providerId: provider.id, credentialId }, { - onSuccess: () => toast.success(`已切换 ${provider.displayName} 账号`), - onError: (error) => toast.error(error instanceof Error ? error.message : '切换账号失败'), - }) - } - - const handleUpdateProviderCredentialPoolMode = (provider: Provider, mode: ProviderCredentialPoolMode) => { - updateProviderCredentialPoolMode.mutate({ providerId: provider.id, mode }, { - onSuccess: () => toast.success(`已更新 ${provider.displayName} 号池策略`), - onError: (error) => toast.error(error instanceof Error ? error.message : '更新号池策略失败'), - }) - } - - const handleDeleteProviderCredential = () => { - if (!credentialDeleteTarget) return - const { provider, credential } = credentialDeleteTarget - deleteProviderCredential.mutate({ providerId: provider.id, credentialId: credential.id }, { - onSuccess: () => { - toast.success(`已删除账号 ${credential.name}`) - setCredentialDeleteTarget(null) - }, - onError: (error) => toast.error(error instanceof Error ? error.message : '删除账号失败'), - }) - } - const handleDeleteProvider = (force = false) => { if (!deleteTarget) return deleteProvider.mutate({ providerId: deleteTarget.id, force }, { @@ -1081,18 +978,12 @@ export function ModelsPage() { }} onCopy={copyText} onAlias={openAliasDialog} - onCreateCredential={() => openCredentialDialog(provider)} - onEditCredential={(credential) => openCredentialDialog(provider, credential)} - onSelectCredential={(credentialId) => handleSelectProviderCredential(provider, credentialId)} - onUpdateCredentialPoolMode={(mode) => handleUpdateProviderCredentialPoolMode(provider, mode)} - onDeleteCredential={(credential) => setCredentialDeleteTarget({ provider, credential })} onToggleModel={(model, enabled) => handleToggleProviderModel(provider, model, enabled)} onBulkToggleModels={(enabled) => handleBulkToggleProviderModels(provider, enabled)} onSetDefaultModel={(model) => handleSetDefaultModel(provider, model)} onEditModel={(item) => setEditingModelAdaptation({ provider, item })} modelMutationKey={modelMutationKey} bulkModelMutation={bulkModelMutation} - credentialBusy={selectProviderCredential.isPending || updateProviderCredentialPoolMode.isPending || deleteProviderCredential.isPending} defaultModelMutationKey={defaultModelMutationKey} /> ))} @@ -1712,87 +1603,6 @@ export function ModelsPage() { onClose={() => setEditingModelAdaptation(null)} />} - { if (!open) closeCredentialDialog() }}> - - - {editingCredential ? '编辑上游账号' : '新增上游账号'} - - 账号只保存环境变量名;真实 API Key 仍放在 .env、容器 Secret 或系统环境变量中。保存后需重启并重新运行 Provider 连接测试。 - - -
- {!editingCredential && ( - - setCredentialForm({ ...credentialForm, id: event.target.value.toLowerCase() })} - placeholder="例如: account-a" - aria-invalid={credentialSubmitAttempted && Boolean(credentialValidation.errors.id)} - aria-required="true" - /> - - )} - - setCredentialForm({ ...credentialForm, name: event.target.value })} - placeholder="例如: Mimo 主账号" - aria-invalid={credentialSubmitAttempted && Boolean(credentialValidation.errors.name)} - aria-required="true" - /> - - - setCredentialForm({ ...credentialForm, apiKeyEnv: event.target.value })} - placeholder="例如: MIMO_OPENAI_API_KEY_ALT" - aria-invalid={credentialSubmitAttempted && Boolean(credentialValidation.errors.apiKeyEnv)} - aria-required="true" - /> - - - setCredentialForm({ ...credentialForm, baseUrl: event.target.value })} - placeholder="可选,不填则沿用供应商 Base URL" - aria-invalid={credentialSubmitAttempted && Boolean(credentialValidation.errors.baseUrl)} - /> - -
- setCredentialForm({ ...credentialForm, status: checked ? 'active' : 'disabled' })} - /> -
- {credentialValidation.warnings.length > 0 && ( -
- - {credentialValidation.warnings.join(' ')} -
- )} -
- - - - -
-
- { if (!open) { setDeleteTarget(null) @@ -1869,29 +1679,6 @@ export function ModelsPage() { - { if (!open) setCredentialDeleteTarget(null) }}> - - - 删除上游账号 - 账号配置和健康记录会删除;真实环境变量不会被修改。 - -
-

{credentialDeleteTarget?.credential.name}

-

{credentialDeleteTarget?.credential.apiKeyEnv}

- {credentialDeleteTarget?.credential.active && ( -

这是当前账号;删除后系统会选择其他可用账号,若没有候选则 Provider 可能不可路由。

- )} -
- - - - -
-
- { if (!open) setAliasDeleteTarget(null) }}> @@ -1991,7 +1778,7 @@ function ProviderRoutingOverview({ onOpenRouting: () => void }) { const credentialReady = defaultProvider - ? defaultProvider.hasApiKey || !defaultProvider.apiKeyRequired + ? providerCredentialState(defaultProvider).credentialReady : false return ( @@ -2126,18 +1913,12 @@ function ProviderCard({ onDelete, onCopy, onAlias, - onCreateCredential, - onEditCredential, - onSelectCredential, - onUpdateCredentialPoolMode, - onDeleteCredential, onToggleModel, onBulkToggleModels, onSetDefaultModel, onEditModel, modelMutationKey, bulkModelMutation, - credentialBusy, defaultModelMutationKey, }: { provider: Provider @@ -2156,24 +1937,15 @@ function ProviderCard({ onDelete: () => void onCopy: (value: string) => Promise onAlias: (alias?: string, target?: string) => void - onCreateCredential: () => void - onEditCredential: (credential: ProviderCredential) => void - onSelectCredential: (credentialId: string) => void - onUpdateCredentialPoolMode: (mode: ProviderCredentialPoolMode) => void - onDeleteCredential: (credential: ProviderCredential) => void onToggleModel: (model: string, enabled: boolean) => void onBulkToggleModels: (enabled: boolean) => void onSetDefaultModel: (model: string) => void onEditModel: (item: ProviderModelInventory) => void modelMutationKey: string | null bulkModelMutation: { providerId: string; enabled: boolean } | null - credentialBusy: boolean defaultModelMutationKey: string | null }) { - const credentials = provider.credentials ?? [] - const credentialReady = provider.hasApiKey - || !provider.apiKeyRequired - || credentials.some((credential) => credential.status === 'active' && credential.hasApiKey) + const { credentials, credentialReady, activeCredential } = providerCredentialState(provider) const lastTest = provider.lastTest const connectionVerified = lastTest?.success === true const routeReady = provider.status === 'active' @@ -2186,10 +1958,6 @@ function ProviderCard({ const modelListId = `provider-models-${provider.id}` const identity = providerIdentity(provider) const displayTitle = providerDisplayTitle(provider) - const activeCredential = credentials.find((credential) => credential.active) - ?? credentials.find((credential) => credential.id === provider.activeCredentialId) - ?? null - const credentialPoolMode = provider.credentialPoolMode ?? 'failover' const modelGroups = providerModelGroups(provider) const inventoryGroups = providerInventoryGroups(provider) const inventoryItems = providerInventoryItems(provider) @@ -2290,183 +2058,13 @@ function ProviderCard({ )} -
-
-
-

上游账号

-

- {credentials.length > 0 ? `${credentials.length} 个账号 · ${CREDENTIAL_POOL_MODE_LABELS[credentialPoolMode]}` : '默认凭证'} -

-
-
- - {canManage && } -
-
- {provider.id === 'deepseek' && canManage && ( -
-
-
-
-

DeepSeek 线上余额

- {onlineBalance && ( - - {onlineBalance.isAvailable ? '可调用' : '余额不足'} - - )} -
-

- 实时只读查询;充值、退款与账单以 DeepSeek 控制台为准。 -

-
- -
- {onlineBalance && ( -
- {onlineBalance.balanceInfos.map((balance) => ( -
-

{balance.currency} 可用总额

-

- {balance.totalBalance} {balance.currency} -

-

- 赠金 {balance.grantedBalance} · 充值 {balance.toppedUpBalance} -

-
- ))} -

- 最近查询:{formatRelativeTime(onlineBalance.checkedAt)} -

-
- )} -
- )} - {credentials.length === 0 ? ( -
- - {credentialReady ? '默认环境变量可用' : '缺少默认密钥'} - - {provider.apiKeyEnv || '无需 API Key'} -
- ) : ( -
- -
- {activeCredential && ( - <> - - {activeCredential.hasApiKey ? 'Key 可用' : 'Key 缺失'} - - {canManage && } - {canManage && } - - )} -
- {activeCredential && ( -
-

- 环境变量:{activeCredential.apiKeyEnv} -

- {activeCredential.baseUrl && ( -

- Base URL:{activeCredential.baseUrl} -

- )} -
- )} -
- {credentials.map((credential) => { - const health = credential.health - const healthStatus = health?.status ?? (credential.hasApiKey ? 'healthy' : 'degraded') - const credentialRechargeBadge = health?.rechargeRequired ? '等待充值' : null - return ( -
-
-
- {credential.name} - {credential.active && 当前} - {credential.status === 'disabled' && 禁用} -
-
- {credential.apiKeyEnv} - {health?.lastUsedAt && 最近 {formatRelativeTime(health.lastUsedAt)}} -
-
-
- - {credential.hasApiKey ? 'Key 可用' : 'Key 缺失'} - - - {credentialHealthLabel(healthStatus)} - - {credentialRechargeBadge && {credentialRechargeBadge}} - - {health?.requestsTotal ? `${formatNumber(health.requestsTotal)} 次 · ${Math.round(health.successRate)}%` : '暂无请求'} - -
- {health?.lastError && ( -

{health.lastError}

- )} -
- ) - })} -
-
- )} -
+
{canManage &&