diff --git a/docs/reference/spec-v02-provider-management.md b/docs/reference/spec-v02-provider-management.md index cbc43b7c..3c5da513 100644 --- a/docs/reference/spec-v02-provider-management.md +++ b/docs/reference/spec-v02-provider-management.md @@ -34,7 +34,8 @@ view: provider API Key 管理 页面目标是直接可用的运维界面,不是 landing page。首版至少包含: -- provider profile 列表:`deepseek`、`codex-api`、`minimax-m3`。 +- provider profile 列表来自 `GET /v1/admin/provider-profiles` 的实时返回,不是前端固定枚举;内建友好名至少覆盖 `deepseek`、`dsflash-go`、`codex-api`、`minimax-m3`,其余动态 slug 直接回显并可立即使用。 +- 新增 provider profile slug 属于 AgentRun provider 配置数据变更;当 AgentRun runtime 已支持动态 slug 时,管理员只通过管理页或 `hwlab-cli client provider-profiles set-config/set-key/validate` 即可创建、配置和验证新 slug,HWLAB 不再为每个新 slug 增加 cloud-api/web 静态枚举,也不为此触发专门服务代码改动或单独 CI/CD。 - 每个 profile 的配置状态:`configured`、SecretRef、key hash 后缀、resourceVersion、最近更新时间、最近验证结果。 - API Key 写入表单:保存后立即清空输入框,不回显旧值。 - DeepSeek 链路摘要:必须显示 `HWLAB Moon Bridge -> DeepSeek 官方 upstream`,并明确不是 hyue 通道。 @@ -47,12 +48,16 @@ view: provider API Key 管理 面向前端的同源 API 固定归属 HWLAB: ```http +GET /v1/provider-profiles GET /v1/admin/provider-profiles PUT /v1/admin/provider-profiles/:profile/credential POST /v1/admin/provider-profiles/:profile/validate GET /v1/admin/provider-profiles/:profile/validations/:validationId ``` +- `/v1/provider-profiles` 是已认证用户可读的公开 catalog,只返回工作台下拉选择所需的 `profile/configured/backendKind/bridge` 等非敏感字段,用于工作台和设置页展示动态 slug。 +- `/v1/admin/provider-profiles*` 继续是管理员管理入口,负责查看 SecretRef/resourceVersion/hash 后缀、写 key/config 和触发 validate。 + 所有接口必须先通过 [spec-v02-auth.md](spec-v02-auth.md) 恢复 `AuthPrincipal`,再按 [spec-user-access.md](spec-user-access.md) 和 [spec-v02-openfga-authorization.md](spec-v02-openfga-authorization.md) 判定是否允许访问管理能力。首版允许只对 `admin` 开放;如果后续引入 `provider_manager` 或等价 relation,必须先写入用户权限规格。 响应必须是 JSON,且不得包含完整 API Key、Kubernetes Secret data、base64 Secret data、Codex `auth.json` 明文或 `config.toml` 明文。允许返回的字段包括: diff --git a/internal/cloud/provider-profile-management.test.ts b/internal/cloud/provider-profile-management.test.ts index e7b0c57a..976ce287 100644 --- a/internal/cloud/provider-profile-management.test.ts +++ b/internal/cloud/provider-profile-management.test.ts @@ -30,7 +30,42 @@ test("provider profile management requires HWLAB admin auth before AgentRun dele } }); -test("provider profiles collection delegates to AgentRun and rewrites codex profile for HWLAB UI", async () => { +test("provider profile catalog is readable to authenticated non-admin actors and redacts management-only fields", async () => { + const calls: Array<{ method?: string; path?: string; body?: unknown }> = []; + const agentRunServer = await startAgentRunServer(calls); + const { port: agentRunPort } = agentRunServer.address() as { port: number }; + const server = createCloudApiServer({ + env: testEnv(agentRunPort), + accessController: fakeAccessController({ actor: userActor }) + }); + await listen(server); + const { port } = server.address() as { port: number }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/provider-profiles`, { headers: { cookie: "hwlab_session=user" } }); + const body = await response.json(); + assert.equal(response.status, 200); + assert.equal(body.ok, true); + assert.equal(body.contractVersion, "provider-profile-management-v1"); + assert.equal(body.count, 4); + const profiles = body.items.map((item: any) => item.profile); + assert.equal(profiles.includes("deepseek"), true); + assert.equal(profiles.includes("codex-api"), true); + assert.equal(profiles.includes("minimax-m3"), true); + assert.equal(profiles.includes("dsflash-go-cli-example"), true); + assert.equal(body.items[0].secretRef, undefined); + assert.equal(body.items[0].credentialHashSuffix, undefined); + assert.equal(body.items[0].bridge.route, "HWLAB Moon Bridge -> DeepSeek official upstream"); + assert.equal(JSON.stringify(body).includes("auth.json"), false); + assert.equal(calls[0].method, "GET"); + assert.equal(calls[0].path, "/api/v1/provider-profiles"); + } finally { + await close(server); + await close(agentRunServer); + } +}); + +test("provider profiles collection delegates to AgentRun, rewrites codex profile, and preserves dynamic slugs", async () => { const calls: Array<{ method?: string; path?: string; body?: unknown }> = []; const agentRunServer = await startAgentRunServer(calls); const { port: agentRunPort } = agentRunServer.address() as { port: number }; @@ -48,7 +83,12 @@ test("provider profiles collection delegates to AgentRun and rewrites codex prof assert.equal(body.ok, true); assert.equal(body.contractVersion, "provider-profile-management-v1"); assert.equal(body.delegation.agentRunHost, "127.0.0.1"); - assert.deepEqual(body.items.map((item: any) => item.profile), ["deepseek", "codex-api", "minimax-m3"]); + const profiles = body.items.map((item: any) => item.profile); + assert.equal(body.count, 4); + assert.equal(profiles.includes("deepseek"), true); + assert.equal(profiles.includes("codex-api"), true); + assert.equal(profiles.includes("minimax-m3"), true); + assert.equal(profiles.includes("dsflash-go-cli-example"), true); assert.equal(body.items[0].bridge.route, "HWLAB Moon Bridge -> DeepSeek official upstream"); assert.equal(JSON.stringify(body).includes("auth.json"), true); assert.equal(JSON.stringify(body).includes("sk-test"), false); @@ -176,7 +216,7 @@ async function startAgentRunServer(calls: Array<{ method?: string; path?: string calls.push({ method: request.method, path: url.pathname, body }); response.writeHead(200, { "content-type": "application/json" }); if (request.method === "GET" && url.pathname === "/api/v1/provider-profiles") { - response.end(`${JSON.stringify({ ok: true, traceId: "trc_profiles", data: { items: [profile("deepseek"), profile("codex"), profile("minimax-m3")], count: 3, valuesPrinted: false } })}\n`); + response.end(`${JSON.stringify({ ok: true, traceId: "trc_profiles", data: { items: [profile("deepseek"), profile("codex"), profile("minimax-m3"), profile("dsflash-go-cli-example")], count: 4, valuesPrinted: false } })}\n`); return; } if (request.method === "PUT" && url.pathname === "/api/v1/provider-profiles/deepseek/credential") { diff --git a/internal/cloud/provider-profile-management.ts b/internal/cloud/provider-profile-management.ts index b45dad03..50504a98 100644 --- a/internal/cloud/provider-profile-management.ts +++ b/internal/cloud/provider-profile-management.ts @@ -9,6 +9,38 @@ const PROVIDER_PROFILE_ALIASES = Object.freeze({ codex: "codex-api" }); const PROVIDER_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u; const RESERVED_PROVIDER_PROFILES = new Set(["runtime-default"]); +export async function handleProviderProfileCatalogHttp(request, response, options = {}) { + try { + const auth = await authenticateActor(request, options); + if (!auth.ok) return sendJson(response, numericStatus(auth.status) ?? numericStatus(auth.statusCode) ?? 401, auth); + + const requestId = requestIdFor(request); + const managerUrl = resolveAgentRunManagerUrl(options.env ?? process.env); + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = positiveInteger((options.env ?? process.env).HWLAB_PROVIDER_PROFILE_AGENTRUN_HTTP_TIMEOUT_MS, positiveInteger((options.env ?? process.env).HWLAB_CODE_AGENT_AGENTRUN_HTTP_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)); + const delegated = await agentRunJson(fetchImpl, managerUrl, "/api/v1/provider-profiles", { method: "GET", requestId, timeoutMs }); + const data = normalizeCatalogData(delegated.data); + const status = delegated.ok ? delegated.httpStatus || 200 : delegated.httpStatus || 502; + const base = { + ok: delegated.ok, + status: delegated.ok ? "ok" : "failed", + contractVersion: CONTRACT_VERSION, + actor: actorSummary(auth.actor), + valuesPrinted: false + }; + if (delegated.ok) { + return sendJson(response, status, { ...base, items: data.items, count: data.count, data }); + } + return sendJson(response, status, { + ...base, + error: delegatedError(delegated), + data: null + }); + } catch (error) { + return sendError(response, error.httpStatus ?? 500, error.code ?? "provider_profile_catalog_failed", error.message ?? "Provider profile catalog failed", error.details); + } +} + export async function handleProviderProfilesHttp(request, response, url, options = {}) { try { const auth = await authenticateAdmin(request, options); @@ -79,13 +111,19 @@ export async function handleProviderProfilesHttp(request, response, url, options } async function authenticateAdmin(request, options) { + const auth = await authenticateActor(request, options); + if (!auth.ok) return auth; + if (auth.actor?.role !== "admin") return errorPayload("admin_required", "Only admin users can manage provider profiles", 403); + return auth; +} + +async function authenticateActor(request, options) { const controller = options.accessController; if (!controller || typeof controller.authenticate !== "function") { return errorPayload("access_controller_unavailable", "HWLAB access controller is not available", 503); } const auth = await controller.authenticate(request, { required: true }); if (!auth.ok) return auth; - if (auth.actor?.role !== "admin") return errorPayload("admin_required", "Only admin users can manage provider profiles", 403); return auth; } @@ -205,6 +243,12 @@ function normalizeListData(data) { return { ...redactObject(data), items, count: items.length, valuesPrinted: false }; } +function normalizeCatalogData(data) { + const sourceItems = Array.isArray(data?.items) ? data.items : []; + const items = sourceItems.map(normalizeCatalogProfileData); + return { items, count: items.length, valuesPrinted: false }; +} + function normalizeProfileData(item) { if (!item || typeof item !== "object") return item; const copy = redactObject(item); @@ -218,6 +262,20 @@ function normalizeProfileData(item) { return copy; } +function normalizeCatalogProfileData(item) { + const normalized = normalizeProfileData(item); + if (!normalized || typeof normalized !== "object") return normalized; + return { + profile: normalized.profile, + backendProfile: normalized.backendProfile, + backendKind: normalized.backendKind, + configured: normalized.configured, + failureKind: normalized.failureKind ?? null, + bridge: normalized.bridge, + valuesPrinted: false + }; +} + function normalizeConfigData(data) { if (!data || typeof data !== "object") return data; const configToml = typeof data.configToml === "string" ? data.configToml : typeof data["config.toml"] === "string" ? data["config.toml"] : undefined; diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index e271137c..959d8de4 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -63,7 +63,7 @@ import { } from "./server-code-agent-http.ts"; import { handleM3IoControlHttp } from "./server-m3-http.ts"; import { handleSkillsHttp } from "./server-skills-http.ts"; -import { handleProviderProfilesHttp } from "./provider-profile-management.ts"; +import { handleProviderProfileCatalogHttp, handleProviderProfilesHttp } from "./provider-profile-management.ts"; import { configureSkillRuntime, ensureCodexSkillsAggregationSync @@ -433,6 +433,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/provider-profiles" && request.method === "GET") { + await handleProviderProfileCatalogHttp(request, response, options); + return; + } + if (url.pathname.startsWith("/v1/admin/")) { await options.accessController.handleAdminRoute(request, response, url); return; diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 9b2a6fe3..eca5a396 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -2,6 +2,7 @@ import type { CSSProperties, ReactElement, ReactNode } from "react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAuth } from "./hooks/useAuth"; +import { providerProfileLabel } from "./hooks/useProviderProfileOptions"; import { useSidebarResize } from "./hooks/useSidebarResize"; import { useWorkbenchStore } from "./state/workbench"; import type { RouteId } from "./types/domain"; @@ -127,13 +128,7 @@ export function App(): ReactElement { void fetchText("/help.md", { timeoutMs: 8000 }).then((response) => setHelp(response.ok ? response.data ?? "" : `帮助内容加载失败:${response.error ?? "unknown"}`)); }, [route]); - const modelLabel = useMemo(() => { - if (store.state.providerProfile === "codex-api") return "Codex API"; - if (store.state.providerProfile === "minimax-m3") return "MiniMax-M3"; - if (store.state.providerProfile === "dsflash-go") return "DeepSeek V4 Flash"; - if (store.state.providerProfile === "deepseek") return "DeepSeek"; - return store.state.providerProfile; - }, [store.state.providerProfile]); + const modelLabel = useMemo(() => providerProfileLabel(store.state.providerProfile), [store.state.providerProfile]); const sessionSidebarVisible = workspaceRoute && !leftCollapsed; const hwpodSidebarVisible = workspaceRoute && !rightCollapsed; const shellStyle = useMemo(() => ({ diff --git a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx index 855d46cf..0509317e 100644 --- a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx +++ b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from "react"; import { FormEvent, KeyboardEvent, useEffect, useMemo, useState } from "react"; +import { useProviderProfileOptions } from "../../hooks/useProviderProfileOptions"; import type { ProviderProfile } from "../../types/domain"; import { DraftsList } from "./DraftsList"; @@ -33,6 +34,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason, const [submitting, setSubmitting] = useState(false); const [draftsOpen, setDraftsOpen] = useState(false); const inputRows = useMemo(() => commandInputRows(value), [value]); + const { options: providerOptions } = useProviderProfileOptions(props.providerProfile); useEffect(() => { if (pickedDraft) { @@ -63,10 +65,7 @@ export function CommandBar({ pickedDraft, onPickedDraftConsumed, disabledReason,
diff --git a/web/hwlab-cloud-web/src/components/management/ManagementView.tsx b/web/hwlab-cloud-web/src/components/management/ManagementView.tsx index c202b88f..ca04ab08 100644 --- a/web/hwlab-cloud-web/src/components/management/ManagementView.tsx +++ b/web/hwlab-cloud-web/src/components/management/ManagementView.tsx @@ -1,6 +1,7 @@ import type { ReactElement } from "react"; import { useEffect, useMemo, useState } from "react"; +import { builtinProviderProfileOrder, providerProfileLabel as profileLabel } from "../../hooks/useProviderProfileOptions"; import { api } from "../../services/api/client"; import type { ProviderProfile, ProviderProfileConfig, ProviderProfileStatusItem, ProviderProfileValidation, Tone } from "../../types/domain"; import { formatTimestamp, shortToken, toneClass } from "../../utils"; @@ -27,21 +28,9 @@ interface ManagementState { notice: string | null; } -const profileOrder: ProviderProfile[] = ["deepseek", "dsflash-go", "codex-api", "minimax-m3"]; -const profileLabels: Record = { - deepseek: "DeepSeek", - "dsflash-go": "DeepSeek V4 Flash", - "codex-api": "Codex API", - "minimax-m3": "MiniMax-M3" -}; - -const initialInputs: Record = { deepseek: "", "dsflash-go": "", "codex-api": "", "minimax-m3": "" }; +const initialInputs: Record = {}; const initialState: ManagementState = { loading: false, savingProfile: null, validatingProfile: null, configDialog: null, profiles: [], inputs: initialInputs, validations: {}, error: null, notice: null }; -function profileLabel(profile: ProviderProfile): string { - return profileLabels[profile] ?? profile; -} - export function ManagementView(): ReactElement { const [state, setState] = useState(initialState); @@ -56,7 +45,7 @@ export function ManagementView(): ReactElement { } async function saveKey(profile: ProviderProfile): Promise { - const apiKey = state.inputs[profile].trim(); + const apiKey = (state.inputs[profile] ?? "").trim(); if (apiKey.length < 8 || state.savingProfile || state.validatingProfile) return; setState((current) => ({ ...current, savingProfile: profile, error: null, notice: null })); const response = await api.setProviderProfileKey(profile, apiKey); @@ -255,8 +244,8 @@ function configPayload(payload: unknown, profile: ProviderProfile): ProviderProf function orderedProfiles(items: ProviderProfileStatusItem[]): ProviderProfileStatusItem[] { const byProfile = new Map(items.map((item) => [item.profile, item])); - const ordered = profileOrder.map((profile) => byProfile.get(profile) ?? { profile, configured: false, valuesPrinted: false }); - const extras = items.filter((item) => !profileOrder.includes(item.profile)).sort((left, right) => left.profile.localeCompare(right.profile)); + const ordered = builtinProviderProfileOrder.map((profile) => byProfile.get(profile) ?? { profile, configured: false, valuesPrinted: false }); + const extras = items.filter((item) => !builtinProviderProfileOrder.includes(item.profile)).sort((left, right) => left.profile.localeCompare(right.profile)); return [...ordered, ...extras]; } diff --git a/web/hwlab-cloud-web/src/components/settings/SettingsView.tsx b/web/hwlab-cloud-web/src/components/settings/SettingsView.tsx index 85962f1e..ca3d80cd 100644 --- a/web/hwlab-cloud-web/src/components/settings/SettingsView.tsx +++ b/web/hwlab-cloud-web/src/components/settings/SettingsView.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from "react"; +import { useProviderProfileOptions } from "../../hooks/useProviderProfileOptions"; import type { ProviderProfile } from "../../types/domain"; interface SettingsViewProps { @@ -13,6 +14,7 @@ interface SettingsViewProps { } export function SettingsView({ providerProfile, codeAgentTimeoutMs, gatewayShellTimeoutMs, onProviderProfile, onCodeAgentTimeout, onGatewayShellTimeout, onLogout }: SettingsViewProps): ReactElement { + const { options: providerOptions } = useProviderProfileOptions(providerProfile); return (
@@ -22,10 +24,7 @@ export function SettingsView({ providerProfile, codeAgentTimeoutMs, gatewayShell