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}
+ )}
+
+ )
+ })}
+
+
+ )}
+
+
+
+
+
+ >
+ )
+}
+
+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)}
/>}
-
-
-
-