feat: remove static provider profile gating

This commit is contained in:
Codex Agent
2026-06-08 04:58:29 +08:00
parent 34788702dc
commit 466a5350de
13 changed files with 244 additions and 43 deletions
@@ -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` 明文。允许返回的字段包括:
@@ -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") {
+59 -1
View File
@@ -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;
+6 -1
View File
@@ -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;
+2 -7
View File
@@ -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(() => ({
@@ -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,
<label className="agent-timeout-control" htmlFor="code-agent-provider-profile">
<span></span>
<select id="code-agent-provider-profile" aria-label="Code Agent 模型通道" value={props.providerProfile} disabled={interactionDisabled} onChange={(event) => props.onProviderProfile(event.currentTarget.value as ProviderProfile)}>
<option value="deepseek">DeepSeek</option>
<option value="dsflash-go">DeepSeek V4 Flash</option>
<option value="codex-api">Codex API</option>
<option value="minimax-m3">MiniMax-M3</option>
{providerOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<div className="input-shell">
@@ -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<string, string> = {
deepseek: "DeepSeek",
"dsflash-go": "DeepSeek V4 Flash",
"codex-api": "Codex API",
"minimax-m3": "MiniMax-M3"
};
const initialInputs: Record<string, string> = { deepseek: "", "dsflash-go": "", "codex-api": "", "minimax-m3": "" };
const initialInputs: Record<string, string> = {};
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<ManagementState>(initialState);
@@ -56,7 +45,7 @@ export function ManagementView(): ReactElement {
}
async function saveKey(profile: ProviderProfile): Promise<void> {
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];
}
@@ -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 (
<section className="view settings-view" id="settings" data-view="settings" aria-labelledby="settings-title">
<div className="workspace-panel settings-panel">
@@ -22,10 +24,7 @@ export function SettingsView({ providerProfile, codeAgentTimeoutMs, gatewayShell
<label className="agent-timeout-control" htmlFor="settings-code-agent-provider-profile">
<span></span>
<select id="settings-code-agent-provider-profile" aria-label="Code Agent 模型通道" value={providerProfile} onChange={(event) => onProviderProfile(event.currentTarget.value as ProviderProfile)}>
<option value="deepseek">DeepSeek</option>
<option value="dsflash-go">DeepSeek V4 Flash</option>
<option value="codex-api">Codex API</option>
<option value="minimax-m3">MiniMax-M3</option>
{providerOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="agent-timeout-control" htmlFor="code-agent-timeout">
@@ -0,0 +1,13 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "bun:test";
const sourcePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "useProviderProfileOptions.ts");
test("provider profile options use the public catalog instead of admin management routes", () => {
const source = fs.readFileSync(sourcePath, "utf8");
assert.match(source, /api\.providerProfileCatalog\(\)/u);
assert.doesNotMatch(source, /api\.providerProfiles\(\)/u);
});
@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from "react";
import { api } from "../services/api/client";
import type { ProviderProfile, ProviderProfileStatusItem } from "../types/domain";
export interface ProviderProfileOption {
value: ProviderProfile;
label: string;
configured?: boolean;
}
export const builtinProviderProfileOrder: readonly ProviderProfile[] = Object.freeze(["deepseek", "dsflash-go", "codex-api", "minimax-m3"]);
const builtinProviderProfileLabels: Readonly<Record<string, string>> = Object.freeze({
deepseek: "DeepSeek",
"dsflash-go": "DeepSeek V4 Flash",
"codex-api": "Codex API",
"minimax-m3": "MiniMax-M3"
});
export function providerProfileLabel(profile: ProviderProfile): string {
return builtinProviderProfileLabels[profile] ?? profile;
}
export function useProviderProfileOptions(selectedProfile: ProviderProfile): { options: ProviderProfileOption[] } {
const [options, setOptions] = useState<ProviderProfileOption[]>(() => defaultProviderProfileOptions(selectedProfile));
useEffect(() => {
let cancelled = false;
void (async () => {
const response = await api.providerProfileCatalog();
if (cancelled) return;
if (!response.ok) {
setOptions(defaultProviderProfileOptions(selectedProfile));
return;
}
const items = profileItems(response.data);
setOptions(items.length > 0 ? optionsFromItems(items, selectedProfile) : defaultProviderProfileOptions(selectedProfile));
})();
return () => {
cancelled = true;
};
}, [selectedProfile]);
const normalized = useMemo(() => ensureSelectedProfile(options, selectedProfile), [options, selectedProfile]);
return { options: normalized };
}
function profileItems(payload: unknown): ProviderProfileStatusItem[] {
const record = payload && typeof payload === "object" ? payload as { items?: ProviderProfileStatusItem[]; data?: { items?: ProviderProfileStatusItem[] } } : {};
return Array.isArray(record.items) ? record.items : Array.isArray(record.data?.items) ? record.data.items : [];
}
function optionsFromItems(items: ProviderProfileStatusItem[], selectedProfile: ProviderProfile): ProviderProfileOption[] {
const ordered = [...items]
.filter((item) => typeof item.profile === "string" && item.profile.trim().length > 0)
.sort((left, right) => compareProviderProfiles(left.profile, right.profile))
.map((item) => ({ value: item.profile, label: providerProfileLabel(item.profile), configured: item.configured } satisfies ProviderProfileOption));
return ensureSelectedProfile(uniqueOptions(ordered), selectedProfile);
}
function defaultProviderProfileOptions(selectedProfile: ProviderProfile): ProviderProfileOption[] {
const base = builtinProviderProfileOrder.map((profile) => ({ value: profile, label: providerProfileLabel(profile) } satisfies ProviderProfileOption));
return ensureSelectedProfile(base, selectedProfile);
}
function ensureSelectedProfile(options: ProviderProfileOption[], selectedProfile: ProviderProfile): ProviderProfileOption[] {
const normalized = String(selectedProfile ?? "").trim();
if (!normalized) return uniqueOptions(options);
if (options.some((option) => option.value === normalized)) return uniqueOptions(options);
return uniqueOptions([{ value: normalized, label: providerProfileLabel(normalized) }, ...options]);
}
function uniqueOptions(options: ProviderProfileOption[]): ProviderProfileOption[] {
const seen = new Set<string>();
const result: ProviderProfileOption[] = [];
for (const option of options) {
const value = String(option.value ?? "").trim();
if (!value || seen.has(value)) continue;
seen.add(value);
result.push({ ...option, value });
}
return result;
}
function compareProviderProfiles(left: ProviderProfile, right: ProviderProfile): number {
const leftIndex = builtinProviderProfileOrder.indexOf(left);
const rightIndex = builtinProviderProfileOrder.indexOf(right);
if (leftIndex >= 0 || rightIndex >= 0) {
if (leftIndex < 0) return 1;
if (rightIndex < 0) return -1;
if (leftIndex !== rightIndex) return leftIndex - rightIndex;
}
return left.localeCompare(right);
}
@@ -233,6 +233,7 @@ export const api = {
updateAdminAccessUser: (userId: string, payload: { role?: AccessUserRole; status?: AccessUserStatus }): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin access user update" }),
setAdminAccessTool: (userId: string, toolId: AccessToolId, allowed: boolean): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/tools/${encodeURIComponent(toolId)}/can-use`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access tool" }),
providerProfiles: (): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson("/v1/admin/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profiles" }),
providerProfileCatalog: (): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson("/v1/provider-profiles", { timeoutMs: 12000, timeoutName: "provider profile catalog" }),
setProviderProfileKey: (profile: ProviderProfile, apiKey: string): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/credential`, { method: "PUT", body: JSON.stringify({ apiKey }), timeoutMs: 30000, timeoutName: "provider profile key update" }),
providerProfileConfig: (profile: ProviderProfile): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { timeoutMs: 12000, timeoutName: "provider profile config" }),
setProviderProfileConfig: (profile: ProviderProfile, configToml: string): Promise<ApiResult<ProviderProfilesResponse>> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { method: "PUT", body: JSON.stringify({ configToml }), timeoutMs: 30000, timeoutName: "provider profile config update" }),
@@ -395,8 +395,8 @@ test("subscribeToTrace starts result and trace polls in the same refresh tick (#
const originalClearTimeout = globalThis.window?.clearTimeout;
const fetches: string[] = [];
let traceStartedBeforeResultResolved = false;
let resolveResult: (() => void) | null = null;
const resultGate = new Promise<void>((resolve) => { resolveResult = resolve; });
let releaseResultGate: () => void = () => undefined;
const resultGate = new Promise<void>((resolve) => { releaseResultGate = () => resolve(); });
globalThis.window = {
...(globalThis.window ?? {}),
setTimeout: ((callback: () => void) => { callback(); return 1; }) as typeof window.setTimeout,
@@ -411,7 +411,7 @@ test("subscribeToTrace starts result and trace polls in the same refresh tick (#
}
if (url.includes("/trace/")) {
traceStartedBeforeResultResolved = true;
resolveResult?.();
releaseResultGate();
return jsonResponse({
status: "running",
traceId: "trc_issue1000_parallel",
@@ -438,14 +438,16 @@ test("subscribeToTrace starts result and trace polls in the same refresh tick (#
});
assert.equal(traceStartedBeforeResultResolved, true);
assert.equal(snapshots.length, 1);
assert.equal(snapshots[0].eventCount, 1);
const firstSnapshot = snapshots[0];
assert.ok(firstSnapshot);
assert.equal(firstSnapshot.eventCount, 1);
assert.equal(completed.assistantText, "最终回复");
assert.deepEqual(fetches, [
"/v1/agent/chat/result/trc_issue1000_parallel",
"/v1/agent/chat/trace/trc_issue1000_parallel?projectId=prj_hwpod_workbench"
]);
} finally {
resolveResult?.();
releaseResultGate();
globalThis.fetch = originalFetch;
if (globalThis.window) {
globalThis.window.setTimeout = originalSetTimeout ?? globalThis.window.setTimeout;
@@ -145,5 +145,5 @@ export function readStoredNumber(key: string, fallback: number, min: number, max
export function readProviderProfile(): string {
const value = readStoredString("hwlab.workbench.codeAgentProviderProfile.v1");
return /^[a-z0-9][a-z0-9-]{0,63}$/u.test(value) && value !== "runtime-default" ? value : "deepseek";
return value && /^[a-z0-9][a-z0-9-]{0,63}$/u.test(value) && value !== "runtime-default" ? value : "deepseek";
}