diff --git a/internal/cloud/provider-profile-management.test.ts b/internal/cloud/provider-profile-management.test.ts index 2f118039..26e66a49 100644 --- a/internal/cloud/provider-profile-management.test.ts +++ b/internal/cloud/provider-profile-management.test.ts @@ -294,6 +294,45 @@ test("provider profile remove delegates through HWLAB and preserves redaction", } }); +test("provider profile real test delegates prompt and model through AgentRun validation", async () => { + const calls: Array = []; + const agentRunServer = await startAgentRunServer(calls); + const { port: agentRunPort } = agentRunServer.address() as { port: number }; + const server = createCloudApiServer({ + env: testEnv(agentRunPort), + accessController: fakeAccessController({ actor: adminActor }) + }); + await listen(server); + const { port } = server.address() as { port: number }; + + try { + const response = await fetch(`http://127.0.0.1:${port}/v1/admin/provider-profiles/gpt.pika/test`, { + method: "POST", + headers: { "content-type": "application/json", cookie: "hwlab_session=admin" }, + body: JSON.stringify({ model: "gpt-5.4-mini", prompt: "只回复 OK" }) + }); + const body = await response.json(); + const text = JSON.stringify(body); + assert.equal(response.status, 200); + assert.equal(body.ok, true); + assert.equal(body.profile, "gpt.pika"); + assert.equal(body.data.profile, "gpt.pika"); + assert.equal(body.data.backendProfile, "gpt-pika"); + assert.equal(body.data.model, "gpt-5.4-mini"); + assert.equal(body.data.validationId, "val_1234567890abcdef1234567890abcdef"); + assert.equal(text.includes("sk-test"), false); + assert.equal(calls[0].method, "POST"); + assert.equal(calls[0].path, "/api/v1/provider-profiles/gpt-pika/validate"); + assert.equal(calls[0].authorization, "Bearer test-agentrun-manager-key"); + assert.equal(calls[0].body.model, "gpt-5.4-mini"); + assert.equal(calls[0].body.prompt, "只回复 OK"); + assert.equal(calls[0].body.delegatedBy.system, "hwlab-v02"); + } finally { + await close(server); + await close(agentRunServer); + } +}); + function testEnv(port: number) { return { HWLAB_CODE_AGENT_AGENTRUN_MGR_URL: `http://127.0.0.1:${port}`, @@ -354,6 +393,10 @@ async function startAgentRunServer(calls: Array) { response.end(`${JSON.stringify({ ok: true, traceId: "trc_remove", data: { action: "provider-profile-removed", mutation: true, profile: "deepseek", configured: false, removed: true, builtinCapabilityRetained: true, secretRef: { namespace: "agentrun-v01", name: "agentrun-v01-provider-deepseek", keys: ["auth.json", "config.toml"] }, deletedResourceVersion: "rv-removed", credentialHashSuffix: "abc12345", configHashSuffix: "def67890", updatedAt: "2026-06-05T08:02:00.000Z", valuesPrinted: false } })}\n`); return; } + if (request.method === "POST" && url.pathname === "/api/v1/provider-profiles/gpt-pika/validate") { + response.end(`${JSON.stringify({ ok: true, traceId: "trc_test_submit", data: { action: "provider-profile-validation-started", validationId: "val_1234567890abcdef1234567890abcdef", profile: "gpt-pika", model: body?.model, runId: "run_profile_test", commandId: "cmd_profile_test", jobName: "agentrun-nc01-v02-runner-profile-test", status: "running", valuesPrinted: false } })}\n`); + return; + } response.end(`${JSON.stringify({ ok: false, error: { code: "unexpected", message: `${request.method} ${url.pathname}` } })}\n`); }); await listen(server); diff --git a/internal/cloud/provider-profile-management.ts b/internal/cloud/provider-profile-management.ts index 77d37cb7..a36707ab 100644 --- a/internal/cloud/provider-profile-management.ts +++ b/internal/cloud/provider-profile-management.ts @@ -107,6 +107,7 @@ export async function handleProviderProfilesHttp(request, response, url, options const body = await readOptionalJsonObject(request, options.bodyLimitBytes); const delegatedBody = { ...(text(body.prompt) ? { prompt: text(body.prompt) } : {}), + ...(text(body.model) ? { model: text(body.model) } : {}), delegatedBy: delegatedBy(auth, requestId), reason: text(body.reason) || "hwlab-provider-management" }; @@ -147,7 +148,7 @@ function parseProviderProfileRoute(pathname, method) { if (method !== "GET") throw routeError(405, "method_not_allowed", "Provider profiles collection only supports GET"); return { kind: "list", publicProfile: null, agentRunProfile: null }; } - const match = pathname.match(/^\/v1\/admin\/provider-profiles\/([^/]+)(?:\/(credential|config|validate|validations\/([^/]+)))?$/u); + const match = pathname.match(/^\/v1\/admin\/provider-profiles\/([^/]+)(?:\/(credential|config|validate|test|validations\/([^/]+)))?$/u); if (!match) throw routeError(404, "not_found", "Provider profile route is not implemented"); const profile = normalizeProfile(decodeURIComponent(match[1])); const suffix = match[2] ?? ""; @@ -163,7 +164,7 @@ function parseProviderProfileRoute(pathname, method) { if (method !== "GET" && method !== "PUT") throw routeError(405, "method_not_allowed", "Provider profile config only supports GET and PUT"); return { kind: "config", method, ...profile }; } - if (suffix === "validate") { + if (suffix === "validate" || suffix === "test") { if (method !== "POST") throw routeError(405, "method_not_allowed", "Provider profile validation only supports POST"); return { kind: "validate", ...profile }; } diff --git a/internal/dev-entrypoint/cloud-web-routes.mjs b/internal/dev-entrypoint/cloud-web-routes.mjs index 4b354e1f..1fbb4eb5 100644 --- a/internal/dev-entrypoint/cloud-web-routes.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.mjs @@ -102,6 +102,7 @@ function isProviderProfileManagementProxyRoute(method, pathname) { if (method === "PUT" && parts.length === 5 && parts[4] === "credential") return true; if (method === "PUT" && parts.length === 5 && parts[4] === "config") return true; if (method === "POST" && parts.length === 5 && parts[4] === "validate") return true; + if (method === "POST" && parts.length === 5 && parts[4] === "test") return true; return false; } diff --git a/internal/dev-entrypoint/cloud-web-routes.test.mjs b/internal/dev-entrypoint/cloud-web-routes.test.mjs index 49297318..f88d0257 100644 --- a/internal/dev-entrypoint/cloud-web-routes.test.mjs +++ b/internal/dev-entrypoint/cloud-web-routes.test.mjs @@ -190,6 +190,12 @@ test("cloud web proxies authenticated provider profile management write routes", publicRoute: false, routeKey: "POST /v1/admin/provider-profiles/deepseek/validate" }); + assert.deepEqual(cloudWebProxyRoutePolicy("POST", "/v1/admin/provider-profiles/deepseek/test"), { + proxy: true, + authRequired: true, + publicRoute: false, + routeKey: "POST /v1/admin/provider-profiles/deepseek/test" + }); assert.deepEqual(cloudWebProxyRoutePolicy("DELETE", "/v1/admin/provider-profiles/deepseek"), { proxy: true, authRequired: true, @@ -230,4 +236,3 @@ test("cloud web proxies authenticated Workbench launch writes to cloud-api", () routeKey: "POST /v1/workbench/launches" }); }); - diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index 21c76143..5f0512b2 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -295,7 +295,18 @@ test("cloud web proxies provider profile management write routes", async () => { }); assert.equal(validateResponse.status, 200); - assert.equal(upstreamRequests.length, 2); + const testBody = JSON.stringify({ model: "gpt-5.4-mini", prompt: "只回复 OK" }); + const testResponse = await fetch(`${serverUrl(cloudWeb)}/v1/admin/provider-profiles/deepseek/test`, { + method: "POST", + headers: { + "content-type": "application/json", + authorization: "Bearer hwl_live_admin" + }, + body: testBody + }); + assert.equal(testResponse.status, 200); + + assert.equal(upstreamRequests.length, 3); assert.deepEqual(upstreamRequests[0], { method: "PUT", url: "/v1/admin/provider-profiles/deepseek/credential", @@ -308,6 +319,12 @@ test("cloud web proxies provider profile management write routes", async () => { authorization: "Bearer hwl_live_admin", body: "{}" }); + assert.deepEqual(upstreamRequests[2], { + method: "POST", + url: "/v1/admin/provider-profiles/deepseek/test", + authorization: "Bearer hwl_live_admin", + body: testBody + }); } finally { await close(cloudWeb); await close(upstream); diff --git a/web/hwlab-cloud-web/scripts/provider-profile-real-test.test.ts b/web/hwlab-cloud-web/scripts/provider-profile-real-test.test.ts new file mode 100644 index 00000000..40bfbf91 --- /dev/null +++ b/web/hwlab-cloud-web/scripts/provider-profile-real-test.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { normalizeProviderValidation } from "../src/stores/provider-profiles-view.ts"; + +test("Provider validation exposes real test model and final response", () => { + const validation = normalizeProviderValidation({ + data: { + validationId: "val_profile_test", + profile: "gpt.pika", + model: "gpt-5.4-mini", + status: "completed", + runId: "run_profile_test", + commandId: "cmd_profile_test", + result: { + finalResponse: { text: "HWLAB provider profile test OK" }, + elapsedMs: 1234, + terminalStatus: "completed" + }, + valuesPrinted: false + } + }); + assert.equal(validation.profile, "gpt.pika"); + assert.equal(validation.model, "gpt-5.4-mini"); + assert.equal(validation.outputText, "HWLAB provider profile test OK"); + assert.equal(validation.elapsedMs, "1234"); +}); diff --git a/web/hwlab-cloud-web/src/api/providerProfiles.ts b/web/hwlab-cloud-web/src/api/providerProfiles.ts index a083c79c..d3e96fe3 100644 --- a/web/hwlab-cloud-web/src/api/providerProfiles.ts +++ b/web/hwlab-cloud-web/src/api/providerProfiles.ts @@ -9,5 +9,6 @@ export const providerProfilesAPI = { config: (profile: ProviderProfile): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { timeoutMs: 12000, timeoutName: "provider profile config" }), setConfig: (profile: ProviderProfile, configToml: string): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/config`, { method: "PUT", body: JSON.stringify({ configToml }), timeoutMs: 30000, timeoutName: "provider profile config update" }), validate: (profile: ProviderProfile): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validate`, { method: "POST", body: JSON.stringify({}), timeoutMs: 30000, timeoutName: "provider profile validation submit" }), + test: (profile: ProviderProfile, model: string, prompt: string): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/test`, { method: "POST", body: JSON.stringify({ model, prompt }), timeoutMs: 30000, timeoutName: "provider profile real test submit" }), validation: (profile: ProviderProfile, validationId: string): Promise> => fetchJson(`/v1/admin/provider-profiles/${encodeURIComponent(profile)}/validations/${encodeURIComponent(validationId)}`, { timeoutMs: 12000, timeoutName: "provider profile validation" }) }; diff --git a/web/hwlab-cloud-web/src/stores/provider-profiles-view.ts b/web/hwlab-cloud-web/src/stores/provider-profiles-view.ts index a51d3ac8..0ba0c233 100644 --- a/web/hwlab-cloud-web/src/stores/provider-profiles-view.ts +++ b/web/hwlab-cloud-web/src/stores/provider-profiles-view.ts @@ -26,6 +26,7 @@ export interface ProviderConfigView { export interface ProviderValidationView { validationId: string; profile: string; + model: string; status: string; runId: string; commandId: string; @@ -33,6 +34,8 @@ export interface ProviderValidationView { traceId: string; failureKind: string; terminalStatus: string; + outputText: string; + elapsedMs: string; valuesPrinted: boolean; } @@ -69,6 +72,7 @@ export function normalizeProviderValidation(payload: (ProviderProfilesResponse & return { validationId: text(data.validationId ?? record?.validationId), profile: text(data.profile ?? record?.profile) || fallbackProfile, + model: text(data.model ?? result?.model ?? objectRecord(result?.command)?.model), status: text(data.status ?? record?.status) || "unknown", runId: text(data.runId ?? agentRun?.runId), commandId: text(data.commandId ?? agentRun?.commandId), @@ -76,6 +80,8 @@ export function normalizeProviderValidation(payload: (ProviderProfilesResponse & traceId: text(data.traceId ?? agentRun?.traceId), failureKind: text(data.failureKind ?? result?.failureKind ?? objectRecord(data.error)?.code), terminalStatus: text(data.terminalStatus ?? result?.terminalStatus), + outputText: finalTextFromResult(result), + elapsedMs: scalarText(result?.elapsedMs ?? result?.durationMs ?? agentRun?.elapsedMs), valuesPrinted: data.valuesPrinted === true || record?.valuesPrinted === true }; } @@ -145,8 +151,24 @@ function text(value: unknown): string { return typeof value === "string" ? value.trim() : ""; } +function scalarText(value: unknown): string { + if (typeof value === "number" && Number.isFinite(value)) return String(Math.round(value)); + return text(value); +} + function booleanValue(value: unknown): boolean { if (typeof value === "boolean") return value; if (typeof value === "string") return ["1", "true", "yes", "ready", "configured"].includes(value.toLowerCase()); return Boolean(value); } + +function finalTextFromResult(result: Record | null): string { + if (!result) return ""; + return text( + objectRecord(result.finalResponse)?.text ?? + objectRecord(result.reply)?.content ?? + objectRecord(result.message)?.content ?? + result.assistantText ?? + result.finalText + ); +} diff --git a/web/hwlab-cloud-web/src/views/admin/ProviderProfilesView.vue b/web/hwlab-cloud-web/src/views/admin/ProviderProfilesView.vue index ea765071..a722e093 100644 --- a/web/hwlab-cloud-web/src/views/admin/ProviderProfilesView.vue +++ b/web/hwlab-cloud-web/src/views/admin/ProviderProfilesView.vue @@ -31,10 +31,14 @@ const selectedProfile = ref(null); const credentialDialogOpen = ref(false); const configDialogOpen = ref(false); const removeDialogOpen = ref(false); +const testDialogOpen = ref(false); const apiKeyInput = ref(""); const configText = ref(""); +const testModel = ref(""); +const testPrompt = ref(""); const configInfo = ref(null); const validation = ref(null); +const testResult = ref(null); const validationError = ref(null); const validationApiError = ref(null); const validationDiagnostic = ref(null); @@ -42,6 +46,7 @@ const validatingProfile = ref(null); const credentialForm = useForm(); const configForm = useForm(); const removeForm = useForm(); +const testForm = useForm(); const page = ref(1); const pageSize = 10; @@ -72,6 +77,17 @@ function openRemove(row: ProviderProfileRow): void { removeForm.error.value = null; } +function openTest(row: ProviderProfileRow): void { + selectedProfile.value = row; + testModel.value = defaultModelFor(row); + testPrompt.value = `请用一句中文回复:HWLAB provider profile ${row.profile} 测试通过。`; + testResult.value = null; + testForm.error.value = null; + testForm.apiError.value = null; + testForm.diagnostic.value = null; + testDialogOpen.value = true; +} + async function saveCredential(): Promise { const row = selectedProfile.value; if (!row || !apiKeyInput.value.trim()) return; @@ -164,6 +180,26 @@ async function validateProfile(row: ProviderProfileRow): Promise { void table.reload(); } +async function runRealTest(): Promise { + const row = selectedProfile.value; + if (!row || !testModel.value.trim() || !testPrompt.value.trim()) return; + testResult.value = null; + await testForm.submit(async () => { + const submit = await providerProfilesAPI.test(row.profile, testModel.value.trim(), testPrompt.value.trim()); + if (!submit.ok) throw submit; + let current = normalizeProviderValidation(submit.data, row.profile); + testResult.value = current; + const validationId = current.validationId; + for (let attempt = 0; validationId && attempt < 45 && !validationComplete(current.status); attempt += 1) { + await delay(2000); + const poll = await providerProfilesAPI.validation(row.profile, validationId); + if (!poll.ok) throw poll; + current = normalizeProviderValidation(poll.data, row.profile); + testResult.value = current; + } + }, "Profile test finished"); +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -174,6 +210,13 @@ function displayDate(value: string): string { if (Number.isNaN(date.getTime())) return value; return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } + +function defaultModelFor(row: ProviderProfileRow): string { + const source = row.source || {}; + const configSummary = source.configSummary && typeof source.configSummary === "object" ? source.configSummary as Record : null; + const candidates = [configSummary?.model, source.model, row.profile === "gpt.pika" ? "gpt-5.4-mini" : null, row.profile === "deepseek" ? "deepseek-chat" : null, row.profile === "dsflash-go" ? "deepseek-v4-flash" : null, row.profile === "minimax-m3" ? "MiniMax-M3" : null, "gpt-5.4-mini"]; + return candidates.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim() ?? "gpt-5.4-mini"; +} + +
+ +