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