Add runtime provider controls and searchable AI routing
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Manage provider keys at runtime, route current-information questions through server-side search with safe fallback, and scope OOBE usage claims to grants.
This commit is contained in:
Rocky
2026-08-22 16:33:17 +08:00
parent f8fa93dc48
commit 9fb947aa7d
43 changed files with 2079 additions and 80 deletions
+20
View File
@@ -14,6 +14,9 @@ import type {
CreditGrantResponse,
LedgerQuery,
LedgerEntry,
ManagedProviderId,
ManagedProviderOverview,
ManagedProviderStatus,
OperatorsQuery,
Overview,
PageResult,
@@ -30,6 +33,7 @@ import type {
UpdateHintPackRequest,
UpdateHintFeedSettingsRequest,
UpdateOfficialSkillRequest,
UpdateProviderApiKeyRequest,
} from "./types";
const API_BASE = "/v1/admin";
@@ -89,6 +93,8 @@ function safeMessage(status: number, code?: string): string {
HINT_FEED_GENERATION_IN_PROGRESS: "Hint 提示包正在生成,请稍后刷新",
HINT_FEED_SETTINGS_INVALID: "Hint 自动生成配置不符合要求",
HINT_FEED_GENERATION_FAILED: "Hint 提示包生成失败,旧版本仍保持可用",
PROVIDER_API_KEY_INVALID: "API Key 不符合要求",
PROVIDER_NOT_FOUND: "不支持该 Provider",
RATE_LIMITED: "操作过于频繁,请稍后再试",
};
if (code && messages[code]) return messages[code];
@@ -285,6 +291,20 @@ export const adminApi = {
headers: { "Idempotency-Key": payload.idempotencyKey },
}),
providers: () => request<ManagedProviderOverview>("/providers"),
updateProviderApiKey: (
providerId: ManagedProviderId,
payload: UpdateProviderApiKeyRequest,
) =>
request<ManagedProviderStatus>(
`/providers/${encodeURIComponent(providerId)}/api-key`,
{
method: "PUT",
body: JSON.stringify(payload),
},
),
auditLogs: (value?: string | AuditQuery) =>
request<PageResult<AuditLogEntry>>(
`/audit${encodeQuery(cursorQuery(value))}`,
+21 -1
View File
@@ -19,7 +19,8 @@ export type AdminAuditAction =
| "CONTENT_HINT_PACK_PUBLISHED"
| "CONTENT_HINT_PACK_SAVED"
| "CONTENT_HINT_FEED_SETTINGS_UPDATED"
| "CONTENT_HINT_FEED_GENERATED";
| "CONTENT_HINT_FEED_GENERATED"
| "PROVIDER_API_KEY_UPDATED";
export interface SkillLocalization {
name: string;
@@ -123,6 +124,25 @@ export interface HintFeedGenerationResponse {
en: { version: number; cardCount: number };
}
export type ManagedProviderId = "deepseek" | "volcengine";
export type ProviderCredentialSource = "ENVIRONMENT" | "RUNTIME_OVERRIDE";
export interface ManagedProviderStatus {
providerId: ManagedProviderId;
configured: boolean;
source: ProviderCredentialSource;
updatedAt?: string;
}
export interface ManagedProviderOverview {
providers: ManagedProviderStatus[];
}
export interface UpdateProviderApiKeyRequest {
apiKey: string;
}
export interface CursorPageQuery {
cursor?: string;
limit?: number;
+14
View File
@@ -5,6 +5,7 @@ import {
ChevronRight,
Coins,
GitBranch,
KeyRound,
LibraryBig,
LogOut,
Menu,
@@ -81,6 +82,11 @@ const SecurityPage = lazy(() =>
default: module.SecurityPage,
})),
);
const ProvidersPage = lazy(() =>
import("./features/providers/providers-page").then((module) => ({
default: module.ProvidersPage,
})),
);
interface NavItem {
path: string;
@@ -150,6 +156,13 @@ const navigation: NavItem[] = [
icon: ShieldCheck,
roles: ["SUPER_ADMIN"],
},
{
path: "/providers",
label: "Provider 配置",
description: "上游密钥管理",
icon: KeyRound,
roles: ["SUPER_ADMIN"],
},
];
export function App() {
@@ -224,6 +237,7 @@ function AuthenticatedApp({ role }: { role: AdminRole }) {
<>
<Route path="/audit" element={<AuditPage />} />
<Route path="/security" element={<SecurityPage />} />
<Route path="/providers" element={<ProvidersPage />} />
</>
) : null}
<Route path="*" element={<Navigate to="/overview" replace />} />
@@ -0,0 +1,229 @@
import { KeyRound, RefreshCw, ServerCog } from "lucide-react";
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { toast } from "sonner";
import { adminApi, ApiError } from "../../api/client";
import type {
ManagedProviderId,
ManagedProviderOverview,
ManagedProviderStatus,
} from "../../api/types";
import {
Badge,
Button,
Card,
ErrorState,
Input,
LoadingState,
PageHeader,
} from "../../components/primitives";
import { formatDateTime } from "../../lib/format";
const PROVIDERS: Array<{
id: ManagedProviderId;
name: string;
description: string;
}> = [
{
id: "deepseek",
name: "DeepSeek",
description: "用于润色、AI 和 Agent 等托管文本能力。",
},
{
id: "volcengine",
name: "火山引擎",
description: "用于托管语音识别能力,仅替换 API Key。",
},
];
export function ProvidersPage() {
const [overview, setOverview] = useState<ManagedProviderOverview>();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<unknown>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
try {
setOverview(await adminApi.providers());
} catch (cause) {
setError(cause);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const updateStatus = useCallback((updated: ManagedProviderStatus) => {
setOverview((current) => {
if (!current) return current;
const providers = current.providers.filter(
(item) => item.providerId !== updated.providerId,
);
return { providers: [...providers, updated] };
});
}, []);
if (loading) return <LoadingState label="加载 Provider 配置" />;
if (error || !overview) return <ErrorState error={error} retry={() => void load()} />;
return (
<div className="space-y-6">
<PageHeader
eyebrow="Managed Providers"
title="Provider 配置"
description="替换托管网关使用的上游 API Key。新配置只影响之后开始的请求,现有密钥不会返回浏览器。"
actions={
<Button variant="secondary" onClick={() => void load()}>
<RefreshCw className="size-4" aria-hidden />
</Button>
}
/>
<Card className="border-warning/25 bg-warning-soft/30 p-5 text-sm leading-6 text-foreground sm:p-6">
<div className="flex gap-3">
<span className="mt-0.5 grid size-9 shrink-0 place-items-center rounded-xl bg-warning-soft text-warning">
<KeyRound className="size-4" aria-hidden />
</span>
<p>
Key Key
</p>
</div>
</Card>
<div className="grid gap-5 xl:grid-cols-2">
{PROVIDERS.map((provider) => (
<ProviderCard
key={provider.id}
providerId={provider.id}
name={provider.name}
description={provider.description}
status={overview.providers.find((item) => item.providerId === provider.id)}
onUpdated={updateStatus}
/>
))}
</div>
</div>
);
}
function ProviderCard({
providerId,
name,
description,
status,
onUpdated,
}: {
providerId: ManagedProviderId;
name: string;
description: string;
status?: ManagedProviderStatus;
onUpdated: (status: ManagedProviderStatus) => void;
}) {
const [apiKey, setApiKey] = useState("");
const [saving, setSaving] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalized = apiKey.trim();
if (!validApiKey(normalized)) {
toast.error("API Key 必须为 1512 个字符,且不能包含换行");
return;
}
if (!window.confirm(`确定替换 ${name} API Key?保存后,新请求将立即使用该 Key。`)) {
return;
}
setSaving(true);
try {
const updated = await adminApi.updateProviderApiKey(providerId, {
apiKey: normalized,
});
setApiKey("");
onUpdated(updated);
toast.success(`${name} API Key 已替换`);
} catch (error) {
toast.error(error instanceof ApiError ? error.message : `${name} API Key 替换失败`);
} finally {
setSaving(false);
}
}
return (
<Card className="overflow-hidden">
<div className="border-b border-border bg-surface-muted/35 p-5 sm:p-6">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-primary-soft text-primary">
<ServerCog className="size-5" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-bold">{name}</h2>
<Badge tone={status?.configured ? "success" : "danger"}>
{status?.configured ? "已配置" : "未配置"}
</Badge>
</div>
<p className="mt-1 text-sm leading-6 text-muted">{description}</p>
</div>
</div>
<dl className="mt-5 grid gap-3 text-sm sm:grid-cols-2">
<StatusItem label="当前来源" value={sourceLabel(status)} />
<StatusItem
label="最后替换"
value={status?.updatedAt ? formatDateTime(status.updatedAt) : "随服务器部署"}
/>
</dl>
</div>
<form className="space-y-4 p-5 sm:p-6" onSubmit={(event) => void submit(event)}>
<label className="block text-sm font-semibold" htmlFor={`${providerId}-api-key`}>
<span className="mb-2 block">{name} API Key</span>
<Input
id={`${providerId}-api-key`}
type="password"
autoComplete="new-password"
maxLength={512}
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={`输入新的 ${name} API Key`}
aria-describedby={`${providerId}-api-key-help`}
/>
</label>
<p id={`${providerId}-api-key-help`} className="text-xs leading-5 text-muted">
Key
</p>
<Button type="submit" loading={saving} disabled={!apiKey.trim()}>
<KeyRound className="size-4" aria-hidden />
{name} API Key
</Button>
</form>
</Card>
);
}
function StatusItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-semibold text-muted">{label}</dt>
<dd className="mt-1 font-medium text-foreground">{value}</dd>
</div>
);
}
function sourceLabel(status?: ManagedProviderStatus): string {
if (!status?.configured) return "尚未配置";
return status.source === "RUNTIME_OVERRIDE" ? "管理后台" : "环境变量";
}
function validApiKey(value: string): boolean {
return (
value.length >= 1 &&
value.length <= 512 &&
!value.includes("\n") &&
!value.includes("\r")
);
}
+26
View File
@@ -276,4 +276,30 @@ describe("adminApi", () => {
);
expect(result.totpSecret).toBe("JBSWY3DPEHPK3PXP");
});
it("Provider API Key 替换请求携带 CSRF 且只提交新 Key", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
providerId: "deepseek",
configured: true,
source: "RUNTIME_OVERRIDE",
updatedAt: "2026-08-22T08:00:00Z",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
setCsrfToken("csrf-provider");
await adminApi.updateProviderApiKey("deepseek", { apiKey: "new-provider-key" });
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"/v1/admin/providers/deepseek/api-key",
);
const request = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(request.method).toBe("PUT");
expect((request.headers as Headers).get("X-CSRF-Token")).toBe("csrf-provider");
expect(request.body).toBe(JSON.stringify({ apiKey: "new-provider-key" }));
});
});
+84
View File
@@ -0,0 +1,84 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { adminApi } from "../api/client";
import type { ManagedProviderOverview } from "../api/types";
import { App } from "../app";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
window.location.hash = "";
});
describe("Provider 配置", () => {
it("仅展示配置状态,不会回显现有 API Key", async () => {
mockSession();
mockProviders();
window.location.hash = "#/providers";
render(<App />);
expect(await screen.findByRole("heading", { name: "Provider 配置" })).toBeTruthy();
expect(screen.getByText("DeepSeek")).toBeTruthy();
expect(screen.getByText("火山引擎")).toBeTruthy();
expect(screen.getByText("管理后台")).toBeTruthy();
expect(screen.getByText("环境变量")).toBeTruthy();
expect(document.body.textContent).not.toContain("existing-secret");
});
it("可独立替换 DeepSeek API Key,并在成功后清空输入", async () => {
mockSession();
mockProviders();
vi.spyOn(window, "confirm").mockReturnValue(true);
const update = vi.spyOn(adminApi, "updateProviderApiKey").mockResolvedValue({
providerId: "deepseek",
configured: true,
source: "RUNTIME_OVERRIDE",
updatedAt: "2026-08-22T08:00:00Z",
});
window.location.hash = "#/providers";
render(<App />);
const input = await screen.findByLabelText("DeepSeek 新 API Key");
await userEvent.type(input, "new-deepseek-key");
await userEvent.click(
screen.getByRole("button", { name: "替换 DeepSeek API Key" }),
);
await waitFor(() =>
expect(update).toHaveBeenCalledWith("deepseek", { apiKey: "new-deepseek-key" }),
);
await waitFor(() => expect(input).toHaveProperty("value", ""));
});
});
function mockSession() {
vi.spyOn(adminApi, "session").mockResolvedValue({
authenticated: true,
operatorName: "owner",
role: "SUPER_ADMIN",
});
}
function mockProviders() {
vi.spyOn(adminApi, "providers").mockResolvedValue(providerOverview());
}
function providerOverview(): ManagedProviderOverview {
return {
providers: [
{
providerId: "deepseek",
configured: true,
source: "RUNTIME_OVERRIDE",
updatedAt: "2026-08-22T07:30:00Z",
},
{
providerId: "volcengine",
configured: true,
source: "ENVIRONMENT",
},
],
};
}