From 34788702dcb48599876c4f6bdded2eaa3a24f1a6 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 8 Jun 2026 03:22:26 +0800 Subject: [PATCH] Allow dynamic AgentRun provider profiles --- internal/agent/agentrun-dispatch.mjs | 11 +++-- internal/cloud/code-agent-agentrun-adapter.ts | 38 ++++++++++----- internal/cloud/provider-profile-management.ts | 16 +++++-- internal/cloud/server-agent-chat.test.ts | 10 ++-- internal/cloud/server-code-agent-http.ts | 27 +++++++++-- scripts/g14-gitops-render.mjs | 2 +- tools/hwlab-cli/client.test.ts | 34 ++++++++------ tools/src/hwlab-caserun-lib.ts | 2 +- tools/src/hwlab-cli-lib.ts | 34 ++++++++------ web/hwlab-cloud-web/src/App.tsx | 8 +++- .../src/components/command-bar/CommandBar.tsx | 1 + .../components/management/ManagementView.tsx | 47 +++++++++++-------- .../src/components/settings/SettingsView.tsx | 1 + .../src/state/workbench-reducer.ts | 4 +- web/hwlab-cloud-web/src/types/domain.ts | 2 +- 15 files changed, 154 insertions(+), 83 deletions(-) diff --git a/internal/agent/agentrun-dispatch.mjs b/internal/agent/agentrun-dispatch.mjs index 1fc33a4e..c5bec88f 100644 --- a/internal/agent/agentrun-dispatch.mjs +++ b/internal/agent/agentrun-dispatch.mjs @@ -97,7 +97,8 @@ export const AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES = Object.freeze([ "UNIDESK_SSH_CLIENT_TOKEN" ]); -const backendProfiles = Object.freeze(["codex", "deepseek", "minimax-m3", "ofcx-go"]); +const backendProfileAliases = Object.freeze({ "codex-api": "codex", codex: "codex" }); +const backendProfileIdPattern = /^[a-z0-9][a-z0-9-]{0,63}$/u; const providerSecretKeys = Object.freeze(["auth.json", "config.toml"]); const reusableCredentialEnvNameSet = new Set(AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES); @@ -403,9 +404,11 @@ function unwrapAgentRunEnvelope(path, body) { } function normalizeBackendProfile(value) { - const profile = nonEmptyString(value, "backendProfile"); - if (!backendProfiles.includes(profile)) throw new Error(`unsupported AgentRun backendProfile ${JSON.stringify(profile)}`); - return profile; + const profile = nonEmptyString(value, "backendProfile").toLowerCase(); + if (profile === "runtime-default") throw new Error(`unsupported AgentRun backendProfile ${JSON.stringify(profile)}`); + const normalized = backendProfileAliases[profile] || profile; + if (!backendProfileIdPattern.test(normalized)) throw new Error(`unsupported AgentRun backendProfile ${JSON.stringify(profile)}`); + return normalized; } function fullCommitSha(value) { diff --git a/internal/cloud/code-agent-agentrun-adapter.ts b/internal/cloud/code-agent-agentrun-adapter.ts index 8d900e56..08e2fa4d 100644 --- a/internal/cloud/code-agent-agentrun-adapter.ts +++ b/internal/cloud/code-agent-agentrun-adapter.ts @@ -27,7 +27,8 @@ const AGENTRUN_IMPLEMENTATION_TYPE = "agentrun-v01-shared-execution-infra"; const AGENTRUN_CAPABILITY_LEVEL = "agentrun-v01-shared-code-agent-session"; const AGENTRUN_PROVIDER_TRACE_PROTOCOL = "agentrun-v01-jsonrpc"; const AGENTRUN_PROVIDER_TRACE_WIRE_API = "agentrun-v01-command-result"; -const AGENTRUN_BACKENDS = Object.freeze(["codex", "deepseek", "minimax-m3"]); +const AGENTRUN_BACKEND_ALIASES = Object.freeze({ "codex-api": "codex", codex: "codex" }); +const AGENTRUN_BACKEND_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u; const THREAD_CONTINUITY_POLICY = "hwlab-agentrun-v01-reuse-runner-thread"; const SESSION_POLICY_RUN_LOCAL = "hwlab-agentrun-v01-session-runner-reuse"; const TERMINAL_RUN_STATUSES = new Set(["completed", "failed", "blocked", "cancelled", "canceled"]); @@ -1696,9 +1697,12 @@ function resolveAgentRunManagerUrl(env = process.env, override = null) { } function resolveAgentRunBackendProfile(env = process.env, params = {}) { - const value = String(params.providerProfile ?? params.codeAgentProviderProfile ?? env.HWLAB_CODE_AGENT_AGENTRUN_BACKEND_PROFILE ?? env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE ?? "deepseek").trim().toLowerCase(); - const mapped = value === "codex-api" || value === "codex" ? "codex" : value === "runtime-default" ? String(env.HWLAB_CODE_AGENT_AGENTRUN_DEFAULT_BACKEND_PROFILE ?? "deepseek").trim().toLowerCase() : value; - return AGENTRUN_BACKENDS.includes(mapped) ? mapped : "deepseek"; + const requested = String(params.providerProfile ?? params.codeAgentProviderProfile ?? env.HWLAB_CODE_AGENT_AGENTRUN_BACKEND_PROFILE ?? env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE ?? "deepseek").trim().toLowerCase(); + const fallback = String(env.HWLAB_CODE_AGENT_AGENTRUN_DEFAULT_BACKEND_PROFILE ?? "deepseek").trim().toLowerCase() || "deepseek"; + const resolved = requested === "runtime-default" ? fallback : requested; + if (!resolved || resolved === "runtime-default") return "deepseek"; + const mapped = AGENTRUN_BACKEND_ALIASES[resolved] ?? resolved; + return AGENTRUN_BACKEND_PROFILE_ID_PATTERN.test(mapped) ? mapped : "deepseek"; } function agentRunMapping({ env, managerUrl, backendProfile, run, command, runnerJob, traceId, startedAt, params = {} }) { @@ -1916,17 +1920,27 @@ function requireAgentRunSourceCommit(env) { } function modelForBackendProfile(profile, env = process.env) { - if (profile === "codex") return firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_API_MODEL, env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5"); - if (profile === "minimax-m3") return firstNonEmpty(env.HWLAB_CODE_AGENT_MINIMAX_M3_MODEL, "MiniMax-M3"); - if (profile === "ofcx-go") return firstNonEmpty(env.HWLAB_CODE_AGENT_OFCX_GO_MODEL, "deepseek-v4-flash"); - return firstNonEmpty(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, "deepseek-chat"); + const resolved = resolveAgentRunBackendProfile({}, { providerProfile: profile }); + if (resolved === "codex") return firstNonEmpty(env.HWLAB_CODE_AGENT_CODEX_API_MODEL, env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5"); + if (resolved === "minimax-m3") return firstNonEmpty(env.HWLAB_CODE_AGENT_MINIMAX_M3_MODEL, "MiniMax-M3"); + if (resolved === "deepseek") return firstNonEmpty(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, "deepseek-chat"); + if (resolved === "dsflash-go") return firstNonEmpty(env.HWLAB_CODE_AGENT_AGENTRUN_DSFLASH_GO_MODEL, env.HWLAB_CODE_AGENT_DSFLASH_GO_MODEL, "deepseek-v4-flash"); + const profileEnvKey = dynamicBackendProfileEnvKey(resolved); + return firstNonEmpty(env[`HWLAB_CODE_AGENT_AGENTRUN_${profileEnvKey}_MODEL`], env[`HWLAB_CODE_AGENT_${profileEnvKey}_MODEL`], resolved); } function providerForBackendProfile(profile) { - if (profile === "codex") return "codex-api"; - if (profile === "minimax-m3") return "minimax-m3"; - if (profile === "ofcx-go") return "ofcx-go"; - return "deepseek"; + const resolved = resolveAgentRunBackendProfile({}, { providerProfile: profile }); + if (resolved === "codex") return "codex-api"; + return resolved; +} + +function dynamicBackendProfileEnvKey(profile) { + return String(profile ?? "") + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/gu, "_") + .replace(/^_+|_+$/gu, "") || "PROFILE"; } function agentRunAvailabilityBlocker(error, scope) { diff --git a/internal/cloud/provider-profile-management.ts b/internal/cloud/provider-profile-management.ts index 74fb7c58..b45dad03 100644 --- a/internal/cloud/provider-profile-management.ts +++ b/internal/cloud/provider-profile-management.ts @@ -5,7 +5,9 @@ const DEFAULT_AGENTRUN_MGR_URL = "http://agentrun-mgr.agentrun-v01.svc.cluster.l const DEFAULT_AGENTRUN_HOST = "agentrun-mgr.agentrun-v01.svc.cluster.local"; const DEFAULT_TIMEOUT_MS = 30000; -const publicProfiles = new Set(["deepseek", "codex-api", "minimax-m3", "ofcx-go"]); +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 handleProviderProfilesHttp(request, response, url, options = {}) { try { @@ -117,8 +119,14 @@ function parseProviderProfileRoute(pathname, method) { function normalizeProfile(value) { const input = text(value).toLowerCase(); - const publicProfile = input === "codex" ? "codex-api" : input; - if (!publicProfiles.has(publicProfile)) throw routeError(400, "invalid_provider_profile", "Provider profile is not supported", { profile: input, supportedProfiles: Array.from(publicProfiles) }); + const publicProfile = PROVIDER_PROFILE_ALIASES[input] ?? input; + if (!publicProfile || RESERVED_PROVIDER_PROFILES.has(publicProfile) || !PROVIDER_PROFILE_ID_PATTERN.test(publicProfile)) { + throw routeError(400, "invalid_provider_profile", "Provider profile must be a lowercase slug such as deepseek, minimax-m3, dsflash-go, or codex-api", { + profile: input, + alias: "codex -> codex-api", + pattern: String(PROVIDER_PROFILE_ID_PATTERN) + }); + } return { publicProfile, agentRunProfile: publicProfile === "codex-api" ? "codex" : publicProfile }; } @@ -205,7 +213,7 @@ function normalizeProfileData(item) { copy.profile = "codex-api"; } if (copy.profile === "deepseek") copy.bridge = deepseekBridgeSummary(); - if (copy.profile === "ofcx-go") copy.bridge = opencodeZenGoBridgeSummary(); + if (text(copy.configSummary?.model ?? copy.model) === "deepseek-v4-flash") copy.bridge = opencodeZenGoBridgeSummary(); copy.valuesPrinted = false; return copy; } diff --git a/internal/cloud/server-agent-chat.test.ts b/internal/cloud/server-agent-chat.test.ts index 15fe2b40..55666204 100644 --- a/internal/cloud/server-agent-chat.test.ts +++ b/internal/cloud/server-agent-chat.test.ts @@ -127,7 +127,9 @@ test("AgentRun adapter filters resource tools and credentials through access cap assert.equal(result.status, "running"); const createRun = calls.find((call) => call.method === "POST" && call.path === "/api/v1/runs"); - assert.deepEqual(createRun.body.resourceBundleRef.toolAliases, []); + assert.deepEqual(createRun.body.resourceBundleRef.toolAliases, [ + { name: "hwlab-code-agent", path: "tools/hwlab-code-agent-cli.ts", kind: "bun-script" } + ]); assert.deepEqual(createRun.body.executionPolicy.secretScope.toolCredentials, []); const runnerJob = calls.find((call) => call.method === "POST" && call.path === "/api/v1/runs/run_access_tools/runner-jobs"); assert.equal(runnerJob.body.transientEnv.some((entry) => entry.name === "HWLAB_API_KEY"), false); @@ -297,7 +299,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte { name: "hwpod", path: "tools/hwpod-cli.ts", kind: "bun-script" }, { name: "hwpod-ctl", path: "tools/hwpod-ctl.ts", kind: "bun-script" }, { name: "hwpod-compiler", path: "tools/hwpod-compiler-cli.ts", kind: "bun-script" }, - { name: "unidesk-ssh", path: "tools/unidesk-ssh.mjs", kind: "bun-script" } + { name: "unidesk-ssh", path: "tools/unidesk-ssh.mjs", kind: "bun-script" }, + { name: "hwlab-code-agent", path: "tools/hwlab-code-agent-cli.ts", kind: "bun-script" } ]); assert.deepEqual(body.resourceBundleRef.promptRefs, [ { name: "hwlab-v02-runtime", path: "internal/agent/prompts/hwlab-v02-runtime.md", inject: "thread-start", required: true } @@ -305,7 +308,8 @@ test("cloud api /v1/agent/chat delegates v0.2 turns to AgentRun v0.1 over adapte assert.deepEqual(body.resourceBundleRef.skillRefs, [ { name: "hwpod-cli", path: "skills/hwpod-cli/SKILL.md", required: true, aggregateAs: "hwpod-cli" }, { name: "hwpod-ctl", path: "skills/hwpod-ctl/SKILL.md", required: true, aggregateAs: "hwpod-ctl" }, - { name: "hwlab-agent-runtime", path: "skills/hwlab-agent-runtime/SKILL.md", required: true, aggregateAs: "hwlab-agent-runtime" } + { name: "hwlab-agent-runtime", path: "skills/hwlab-agent-runtime/SKILL.md", required: true, aggregateAs: "hwlab-agent-runtime" }, + { name: "hwlab-code-agent", path: "skills/hwlab-code-agent/SKILL.md", required: true, aggregateAs: "hwlab-code-agent" } ]); assert.deepEqual(body.resourceBundleRef.workspaceFiles, [ { path: ".hwlab/hwpod-spec.yaml", content: "kind: Hwpod\nspec:\n workspace:\n path: F:\\Work\\CASE\n", encoding: "utf8" } diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 3ee90dda..e592d222 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -34,7 +34,8 @@ const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120; const DEFAULT_CODE_AGENT_PROJECT_ID = "prj_hwpod_workbench"; const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek"; const CODE_AGENT_TERMINAL_STATUSES = new Set(["completed", "failed", "blocked", "timeout", "cancelled", "canceled"]); -const CODE_AGENT_PROVIDER_PROFILE_IDS = Object.freeze(["deepseek", "codex-api", "minimax-m3", "ofcx-go", "runtime-default"]); +const CODE_AGENT_PROVIDER_PROFILE_ALIASES = Object.freeze({ codex: "codex-api" }); +const CODE_AGENT_PROVIDER_PROFILE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u; const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({ "deepseek": "DeepSeek", "codex-api": "Codex API", @@ -286,7 +287,7 @@ async function prepareManualCodeAgentSession({ params, options, traceId, respons message: "Code Agent session is explicit in HWLAB v0.2; create/select a session before sending a turn.", traceId, retryable: true, - nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3", "hwlab-cli client agent send --session-id --message TEXT"] + nextCommands: ["hwlab-cli client agent session create --provider-profile PROFILE", "hwlab-cli client agent send --session-id --message TEXT"] })); return { blocked: true }; } @@ -620,8 +621,13 @@ function codeAgentProviderProfileEnv(env = process.env, params = {}) { } else if (profile === "minimax-m3") { overlay.HWLAB_CODE_AGENT_MODEL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_MINIMAX_M3_MODEL, DEFAULT_CODE_AGENT_MINIMAX_M3_MODEL); } else { - overlay.HWLAB_CODE_AGENT_MODEL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, DEFAULT_CODE_AGENT_DEEPSEEK_MODEL); - overlay.HWLAB_CODE_AGENT_OPENAI_BASE_URL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL, defaultDeepSeekBaseUrlForEnv(env)); + const dynamicProfile = profile !== "deepseek"; + overlay.HWLAB_CODE_AGENT_MODEL = dynamicProfile + ? firstNonEmptyValue(env[`HWLAB_CODE_AGENT_${providerProfileEnvKey(profile)}_MODEL`], profile) + : firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, DEFAULT_CODE_AGENT_DEEPSEEK_MODEL); + if (!dynamicProfile || !codeAgentAgentRunAdapterEnabled(env)) { + overlay.HWLAB_CODE_AGENT_OPENAI_BASE_URL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL, defaultDeepSeekBaseUrlForEnv(env)); + } } overlay.NO_PROXY = mergeNoProxyEntries(env.NO_PROXY, codeAgentClusterNoProxyEntries(env)); overlay.no_proxy = mergeNoProxyEntries(env.no_proxy ?? env.NO_PROXY, codeAgentClusterNoProxyEntries(env)); @@ -630,7 +636,18 @@ function codeAgentProviderProfileEnv(env = process.env, params = {}) { function normalizeCodeAgentProviderProfile(value) { const text = String(value ?? DEFAULT_CODE_AGENT_PROVIDER_PROFILE).trim().toLowerCase(); - return CODE_AGENT_PROVIDER_PROFILE_IDS.includes(text) ? text : DEFAULT_CODE_AGENT_PROVIDER_PROFILE; + if (!text) return DEFAULT_CODE_AGENT_PROVIDER_PROFILE; + if (text === "runtime-default") return text; + const normalized = CODE_AGENT_PROVIDER_PROFILE_ALIASES[text] ?? text; + return CODE_AGENT_PROVIDER_PROFILE_ID_PATTERN.test(normalized) ? normalized : DEFAULT_CODE_AGENT_PROVIDER_PROFILE; +} + +function providerProfileEnvKey(profile) { + return String(profile ?? "") + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/gu, "_") + .replace(/^_+|_+$/gu, "") || "PROFILE"; } function defaultDeepSeekBaseUrlForEnv(env = process.env) { diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index fb33684c..b97eedf0 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -4313,7 +4313,7 @@ providers: base_url: "https://opencode.ai/zen/go/v1" api_key: "\${OPENCODE_API_KEY:-}" protocol: "openai-chat" - user_agent: "moonbridge-ofcx-go/1.0" + user_agent: "moonbridge-dsflash-go/1.0" web_search: support: "disabled" offers: diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index c46e2ced..f3328bf1 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -81,7 +81,8 @@ test("hwlab-cli client access uses API key auth on Cloud Web admin access routes test("hwlab-cli client provider-profiles uses Cloud Web admin routes", async () => { const calls: any[] = []; - const result = await runHwlabCli(["client", "provider-profiles", "list", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-admin"], { + const result = await runHwlabCli(["client", "provider-profiles", "list", "--base-url", "http://web.test"], { + env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, fetchImpl: async (url, init) => { calls.push({ url: String(url), init }); return new Response(JSON.stringify({ @@ -96,8 +97,10 @@ test("hwlab-cli client provider-profiles uses Cloud Web admin routes", async () assert.equal(result.exitCode, 0); assert.equal(calls[0].url, "http://web.test/v1/admin/provider-profiles"); assert.equal(calls[0].init.method, "GET"); - assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-admin"); + assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key"); + assert.equal(calls[0].init.headers.cookie, undefined); assert.equal(result.payload.route.path, "/v1/admin/provider-profiles"); + assert.equal(result.payload.auth.authMethod, "api-key"); assert.equal(result.payload.body.items[0].profile, "deepseek"); assert.equal(result.payload.body.items[0].bridge.route, "HWLAB Moon Bridge -> DeepSeek official upstream"); assert.equal(JSON.stringify(result.payload).includes("sk-"), false); @@ -1812,14 +1815,16 @@ test("hwlab-cli client agent result ignores stored Web session by default after }); test("hwlab-cli client protected agent commands return structured auth diagnosis for forbidden trace", async () => { - const result = await runHwlabCli(["client", "agent", "trace", "trc_forbidden", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { + const result = await runHwlabCli(["client", "agent", "trace", "trc_forbidden", "--base-url", "http://web.test"], { + env: { HWLAB_API_KEY: "hwl_live_owner_test_key" }, fetchImpl: async () => new Response(JSON.stringify({ error: { code: "agent_session_owner_required" } }), { status: 403 }) }); assert.equal(result.exitCode, 1); assert.equal(result.payload.httpStatus, 403); assert.equal(result.payload.authDiagnosis.code, "auth_forbidden"); - assert.equal(result.payload.authDiagnosis.cookieSource, "explicit"); + assert.equal(result.payload.auth.authMethod, "api-key"); + assert.equal(result.payload.authDiagnosis.cookieSource, null); assert.match(result.payload.authDiagnosis.message, /无权访问/u); assert.equal(result.payload.request.url, "http://web.test/v1/agent/chat/trace/trc_forbidden"); assert.match(result.payload.authDiagnosis.nextCommands[1], /client auth whoami/u); @@ -1829,6 +1834,7 @@ test("hwlab-cli client protected agent command reports missing credentials with const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-missing-")); const result = await runHwlabCli(["client", "agent", "result", "trc_missing_auth", "--base-url", "http://web.test"], { cwd, + env: {}, fetchImpl: async () => new Response(JSON.stringify({ ok: false, error: { code: "auth_required" } }), { status: 401 }) }); @@ -1858,10 +1864,9 @@ test("hwlab-cli client agent inspect builds protected inspect query", async () = "--thread-id", "thread-1", "--base-url", - "http://web.test", - "--cookie", - "hwlab_session=session-a" + "http://web.test" ], { + env: { HWLAB_API_KEY: "hwl_live_owner_test_key" }, fetchImpl: async (url, init) => { calls.push({ url: String(url), init }); return new Response(JSON.stringify({ ok: true, status: "found", latestTraceId: "trc_inspect" }), { status: 200 }); @@ -1870,7 +1875,8 @@ test("hwlab-cli client agent inspect builds protected inspect query", async () = assert.equal(result.exitCode, 0); assert.equal(calls[0].url, "http://web.test/v1/agent/chat/inspect?traceId=trc_inspect&conversationId=cnv_inspect&sessionId=ses_inspect&threadId=thread-1"); - assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-a"); + assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_owner_test_key"); + assert.equal(calls[0].init.headers.cookie, undefined); assert.equal(result.payload.action, "client.agent.inspect"); assert.equal(result.payload.body.status, "found"); }); @@ -2014,11 +2020,10 @@ test("hwlab-cli client request covers arbitrary Cloud Web same-origin API routes "/v1/access/status", "--base-url", "http://web.test", - "--cookie", - "hwlab_session=session-a", "--trace-id", "trc_request" ], { + env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, fetchImpl: async (url, init) => { calls.push({ url: String(url), init }); return new Response(JSON.stringify({ ok: true, authenticated: true, actor: { username: "admin" }, roles: ["admin"] }), { status: 200 }); @@ -2027,7 +2032,8 @@ test("hwlab-cli client request covers arbitrary Cloud Web same-origin API routes assert.equal(result.exitCode, 0); assert.equal(calls[0].url, "http://web.test/v1/access/status"); - assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-a"); + assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key"); + assert.equal(calls[0].init.headers.cookie, undefined); assert.equal(calls[0].init.headers["x-trace-id"], "trc_request"); assert.equal(result.payload.action, "client.request"); assert.equal(result.payload.route.path, "/v1/access/status"); @@ -2063,8 +2069,6 @@ test("hwlab-cli client rpc mirrors Cloud Web JSON-RPC envelope metadata", async "system.health", "--base-url", "http://web.test", - "--cookie", - "hwlab_session=session-a", "--trace-id", "trc_rpc", "--id", @@ -2072,6 +2076,7 @@ test("hwlab-cli client rpc mirrors Cloud Web JSON-RPC envelope metadata", async "--params-json", "{}" ], { + env: { HWLAB_API_KEY: "hwl_live_admin_test_key" }, fetchImpl: async (url, init) => { calls.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) }); return new Response(JSON.stringify({ jsonrpc: "2.0", id: "req_rpc", result: { status: "ok" }, meta: { traceId: "trc_rpc", serviceId: "hwlab-cloud-api", environment: "dev" } }), { status: 200 }); @@ -2080,7 +2085,8 @@ test("hwlab-cli client rpc mirrors Cloud Web JSON-RPC envelope metadata", async assert.equal(result.exitCode, 0); assert.equal(calls[0].url, "http://web.test/json-rpc"); - assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-a"); + assert.equal(calls[0].init.headers.authorization, "Bearer hwl_live_admin_test_key"); + assert.equal(calls[0].init.headers.cookie, undefined); assert.equal(calls[0].body.method, "system.health"); assert.deepEqual(calls[0].body.params, {}); assert.deepEqual(calls[0].body.meta, { traceId: "trc_rpc", serviceId: "hwlab-cloud-web", environment: "dev" }); diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index d06e9715..29d2c37d 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -250,7 +250,7 @@ export function caseHelp() { `case prepare CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`, `case build CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-dir DIR]`, `case collect CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} --run-dir DIR`, - `case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile deepseek|codex-api|minimax-m3]`, + `case run CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--provider-profile PROFILE]`, `case run start CASE_ID --case-repo ${STANDARD_CASE_REPO_PATH} [--run-id RUN_ID]`, `case run status RUN_ID [--state-dir .state/hwlab-cli/caserun]`, `case run result RUN_ID [--state-dir .state/hwlab-cli/caserun]`, diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index f82b7d54..2398a7f9 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -149,16 +149,16 @@ function help() { "hwlab-cli client request GET /v1/access/status [--full]", "hwlab-cli client request GET /v1/web-performance/summary [--full]", "hwlab-cli client rpc system.health [--full]", - "hwlab-cli client agent session create --provider-profile minimax-m3", + "hwlab-cli client agent session create --provider-profile PROFILE", "hwlab-cli client agent session select SESSION_ID", - "hwlab-cli client agent send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile deepseek|codex-api|minimax-m3", + "hwlab-cli client agent send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] --provider-profile PROFILE", "hwlab-cli client agent send --message TEXT --wait --timeout-ms 50000", "hwlab-cli client agent result TRACE_ID", "hwlab-cli client agent trace TRACE_ID [--render web]", "hwlab-cli client agent inspect --trace-id TRACE_ID", "hwlab-cli client agent cancel TRACE_ID", "hwlab-cli client agent steer TRACE_ID --message TEXT|--message-file PATH", - "hwlab-cli client harness submit --message TEXT --provider-profile deepseek|codex-api|minimax-m3", + "hwlab-cli client harness submit --message TEXT --provider-profile PROFILE", "hwlab-cli client harness wait TRACE_ID --timeout-ms 50000", "hwlab-cli client harness audit TRACE_ID", "hwlab-cli case run CASE_ID --case-repo /root/hwlab-case-registry" @@ -428,8 +428,12 @@ async function providerProfileConfigFromStdin(context: any) { function normalizeProviderProfile(value: unknown) { const profile = text(value).toLowerCase(); const normalized = profile === "codex" ? "codex-api" : profile; - if (["deepseek", "codex-api", "minimax-m3"].includes(normalized)) return normalized; - throw cliError("invalid_provider_profile", "provider profile must be deepseek, codex-api, or minimax-m3", { profile }); + if (/^[a-z0-9][a-z0-9-]{0,63}$/u.test(normalized) && normalized !== "runtime-default") return normalized; + throw cliError("invalid_provider_profile", "provider profile must be a lowercase slug such as deepseek, minimax-m3, dsflash-go, or codex-api", { + profile, + alias: "codex -> codex-api", + pattern: "^[a-z0-9][a-z0-9-]{0,63}$" + }); } function validationTerminalStatus(body: any) { @@ -874,11 +878,11 @@ function agentHelp() { serviceRuntime: false, imagePublished: false, commands: [ - "session create [--provider-profile deepseek|codex-api|minimax-m3] [--conversation-id ID]", + "session create [--provider-profile PROFILE] [--conversation-id ID]", "session select SESSION_ID [--workspace-id ID]", "session status SESSION_ID", "session list [--limit N]", - "send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] [--provider-profile deepseek|codex-api|minimax-m3] [default: short return]", + "send --message TEXT|--message-file PATH [--from-trace TRACE] [--conversation-id ID] [--session-id ID] [--thread-id ID] [--retry-of TRACE] [--provider-profile PROFILE] [default: short return]", "composer status [--profile NAME] [--full]", "composer submit --message TEXT|--message-file PATH [--profile NAME] [default: auto turn/steer]", "send --message TEXT --wait [--timeout-ms N] [--poll-interval-ms N]", @@ -1177,7 +1181,7 @@ function agentSessionHelp() { serviceRuntime: false, imagePublished: false, commands: [ - "create [--provider-profile deepseek|codex-api|minimax-m3] [--conversation-id ID]", + "create [--provider-profile PROFILE] [--conversation-id ID]", "select SESSION_ID [--workspace-id ID]", "status SESSION_ID", "list [--limit N]" @@ -1190,7 +1194,7 @@ async function agentSessionCreate(context: any) { const body = clean({ conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId), - providerProfile: text(context.parsed.providerProfile) || "minimax-m3", + providerProfile: text(context.parsed.providerProfile) || text(workspaceState?.providerProfile) || "deepseek", projectId: text(context.parsed.projectId) || DEFAULT_WORKBENCH_PROJECT_ID, workspaceId: text(context.parsed.workspaceId) || text(workspaceState?.workspaceId), updatedByClient: "hwlab-cli" @@ -1444,7 +1448,7 @@ async function resolveAgentReplayContext(context: any) { sessionId: sessionId || null, threadId: threadId || null, sessionStatus, - nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"] + nextCommands: ["hwlab-cli client agent session create --provider-profile PROFILE"] }); } return { @@ -1503,7 +1507,7 @@ function harnessHelp() { aliases: ["harness", "harness-ops", "harness-opt"], commands: [ "health [--base-url URL]", - "submit --message TEXT|--message-file PATH [--conversation-id ID] [--trace-id ID] [--provider-profile deepseek|codex-api|minimax-m3]", + "submit --message TEXT|--message-file PATH [--conversation-id ID] [--trace-id ID] [--provider-profile PROFILE]", "result TRACE_ID [--full]", "trace TRACE_ID [--limit N] [--full]", "wait TRACE_ID [--timeout-ms N] [--poll-interval-ms N]", @@ -1707,8 +1711,8 @@ async function agentSend(context: any) { traceId, conversationId: conversationId || null, nextCommands: [ - "hwlab-cli client agent session create --provider-profile minimax-m3", - "hwlab-cli client agent send --session-id --message TEXT --provider-profile minimax-m3" + "hwlab-cli client agent session create --provider-profile PROFILE", + "hwlab-cli client agent send --session-id --message TEXT --provider-profile PROFILE" ] }); } @@ -1719,14 +1723,14 @@ async function agentSend(context: any) { conversationId: conversationId || null, sessionId, sessionStatus, - nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"] + nextCommands: ["hwlab-cli client agent session create --provider-profile PROFILE"] }); } if (!conversationId) { throw cliError("session_conversation_required", "selected Code Agent session does not expose conversationId; create a new session before sending", { traceId, sessionId, - nextCommands: ["hwlab-cli client agent session create --provider-profile minimax-m3"] + nextCommands: ["hwlab-cli client agent session create --provider-profile PROFILE"] }); } const threadId = text(parsed.threadId) || replay.threadId || (explicitSessionRecord ? text(explicitSessionRecord.threadId) : "") || (workspaceMatchesSelectedSession ? text(workspaceState?.threadId) : ""); diff --git a/web/hwlab-cloud-web/src/App.tsx b/web/hwlab-cloud-web/src/App.tsx index 7291b388..9b2a6fe3 100644 --- a/web/hwlab-cloud-web/src/App.tsx +++ b/web/hwlab-cloud-web/src/App.tsx @@ -127,7 +127,13 @@ export function App(): ReactElement { void fetchText("/help.md", { timeoutMs: 8000 }).then((response) => setHelp(response.ok ? response.data ?? "" : `帮助内容加载失败:${response.error ?? "unknown"}`)); }, [route]); - const modelLabel = useMemo(() => store.state.providerProfile === "codex-api" ? "Codex API" : store.state.providerProfile === "minimax-m3" ? "MiniMax-M3" : "DeepSeek", [store.state.providerProfile]); + 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 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 90385f53..855d46cf 100644 --- a/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx +++ b/web/hwlab-cloud-web/src/components/command-bar/CommandBar.tsx @@ -64,6 +64,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 c1e1a9a5..c202b88f 100644 --- a/web/hwlab-cloud-web/src/components/management/ManagementView.tsx +++ b/web/hwlab-cloud-web/src/components/management/ManagementView.tsx @@ -21,22 +21,27 @@ interface ManagementState { validatingProfile: ProviderProfile | null; configDialog: ConfigDialogState | null; profiles: ProviderProfileStatusItem[]; - inputs: Record; - validations: Partial>; + inputs: Record; + validations: Partial>; error: string | null; notice: string | null; } -const profileOrder: ProviderProfile[] = ["deepseek", "codex-api", "minimax-m3"]; -const profileLabels: Record = { +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: "", "codex-api": "", "minimax-m3": "" }; +const initialInputs: Record = { deepseek: "", "dsflash-go": "", "codex-api": "", "minimax-m3": "" }; 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 +61,7 @@ export function ManagementView(): ReactElement { setState((current) => ({ ...current, savingProfile: profile, error: null, notice: null })); const response = await api.setProviderProfileKey(profile, apiKey); if (!response.ok) { - setState((current) => ({ ...current, savingProfile: null, error: response.error ?? `${profileLabels[profile]} 保存失败` })); + setState((current) => ({ ...current, savingProfile: null, error: response.error ?? `${profileLabel(profile)} 保存失败` })); return; } const updated = profileItem(response.data); @@ -65,7 +70,7 @@ export function ManagementView(): ReactElement { savingProfile: null, inputs: { ...current.inputs, [profile]: "" }, profiles: updated ? mergeProfile(current.profiles, updated) : current.profiles, - notice: `${profileLabels[profile]} 已保存`, + notice: `${profileLabel(profile)} 已保存`, error: null })); if (!updated) void load(); @@ -76,7 +81,7 @@ export function ManagementView(): ReactElement { setState((current) => ({ ...current, configDialog: { profile, configToml: "", loading: true, saving: false, error: null }, error: null, notice: null })); const response = await api.providerProfileConfig(profile); if (!response.ok) { - setState((current) => ({ ...current, configDialog: current.configDialog?.profile === profile ? { ...current.configDialog, loading: false, error: response.error ?? `${profileLabels[profile]} config.toml 加载失败` } : current.configDialog })); + setState((current) => ({ ...current, configDialog: current.configDialog?.profile === profile ? { ...current.configDialog, loading: false, error: response.error ?? `${profileLabel(profile)} config.toml 加载失败` } : current.configDialog })); return; } const config = configPayload(response.data, profile); @@ -89,7 +94,7 @@ export function ManagementView(): ReactElement { setState((current) => ({ ...current, configDialog: current.configDialog ? { ...current.configDialog, saving: true, error: null } : null, error: null, notice: null })); const response = await api.setProviderProfileConfig(dialog.profile, dialog.configToml); if (!response.ok) { - setState((current) => ({ ...current, configDialog: current.configDialog ? { ...current.configDialog, saving: false, error: response.error ?? `${profileLabels[dialog.profile]} config.toml 保存失败` } : null })); + setState((current) => ({ ...current, configDialog: current.configDialog ? { ...current.configDialog, saving: false, error: response.error ?? `${profileLabel(dialog.profile)} config.toml 保存失败` } : null })); return; } const updated = profileItem(response.data); @@ -97,7 +102,7 @@ export function ManagementView(): ReactElement { ...current, configDialog: null, profiles: updated ? mergeProfile(current.profiles, updated) : current.profiles, - notice: `${profileLabels[dialog.profile]} config.toml 已保存`, + notice: `${profileLabel(dialog.profile)} config.toml 已保存`, error: null })); if (!updated) void load(); @@ -117,13 +122,13 @@ export function ManagementView(): ReactElement { setState((current) => ({ ...current, validatingProfile: profile, error: null, notice: null })); const submitted = await api.validateProviderProfile(profile); if (!submitted.ok) { - setState((current) => ({ ...current, validatingProfile: null, error: submitted.error ?? `${profileLabels[profile]} 验证失败` })); + setState((current) => ({ ...current, validatingProfile: null, error: submitted.error ?? `${profileLabel(profile)} 验证失败` })); return; } const first = validationPayload(submitted.data, profile); setValidation(profile, first); if (!first.validationId) { - setState((current) => ({ ...current, validatingProfile: null, notice: `${profileLabels[profile]} 验证已提交` })); + setState((current) => ({ ...current, validatingProfile: null, notice: `${profileLabel(profile)} 验证已提交` })); return; } let latest = first; @@ -131,13 +136,13 @@ export function ManagementView(): ReactElement { await sleep(2000); const polled = await api.providerProfileValidation(profile, first.validationId); if (!polled.ok) { - setState((current) => ({ ...current, validatingProfile: null, error: polled.error ?? `${profileLabels[profile]} 验证查询失败` })); + setState((current) => ({ ...current, validatingProfile: null, error: polled.error ?? `${profileLabel(profile)} 验证查询失败` })); return; } latest = validationPayload(polled.data, profile); setValidation(profile, latest); } - setState((current) => ({ ...current, validatingProfile: null, notice: `${profileLabels[profile]} 验证 ${latest.status ?? "已更新"}` })); + setState((current) => ({ ...current, validatingProfile: null, notice: `${profileLabel(profile)} 验证 ${latest.status ?? "已更新"}` })); } function setValidation(profile: ProviderProfile, validation: ProviderProfileValidation): void { @@ -166,11 +171,11 @@ export function ManagementView(): ReactElement {
{visibleProfiles.map((profile) => void saveKey(item)} onConfig={(item) => void openConfig(item)} onValidate={(item) => void validateProfile(item)} />)}
- {state.configDialog ? + {state.configDialog ?
{state.configDialog.error ?

{state.configDialog.error}

: null} -