diff --git a/apps/web/.impeccable/surfaces/src-app-tsx.md b/apps/web/.impeccable/surfaces/src-app-tsx.md index 5accf59dd..4c5685ab0 100644 --- a/apps/web/.impeccable/surfaces/src-app-tsx.md +++ b/apps/web/.impeccable/surfaces/src-app-tsx.md @@ -8,7 +8,7 @@ related_targets: ["src/ConsoleApp.tsx"] # Administrator console (operate) Scope: the signed-in console shell and every page behind it, including Getting started on the Overview and the optional console tour. Visitor mode: Operate. -Audience: the administrator of one Parsar Core deployment. Task: judge health, capacity, usage and failures; inspect and delete or copy project assets; manage projects, keys and nodes. +Audience: the administrator of one Parsar Core deployment. Task: judge health, capacity, usage and failures; inspect and delete or copy project assets; manage projects, keys, nodes and each harness's default model. Constraints: Web API only (`/core/v1`); missing data stays visibly missing; no small print, explanations live in help tips; API terms stay English in Chinese copy; zh-CN and English, light and dark. Information architecture: Monitor (Overview, Agent metrics, Sandbox metrics, Session log) · Resources (Agents, Environment templates, Skills, Files, Vaults) · Platform (Projects and keys, Nodes, System). diff --git a/apps/web/.shoot-click.mjs b/apps/web/.shoot-click.mjs deleted file mode 100644 index 0206509d5..000000000 --- a/apps/web/.shoot-click.mjs +++ /dev/null @@ -1,24 +0,0 @@ -// Temporary: open a view, click through steps, screenshot. Delete before commit. -// node .shoot-click.mjs [zh-CN|en] ... ; step = "css=" | "text=" | "row=" -import { chromium } from "@playwright/test"; -const [,, outFile, view, locale = "zh-CN", ...steps] = process.argv; -const THEME = process.env.THEME || "light"; -const browser = await chromium.launch({ headless: true, executablePath: `${process.env.HOME}/.cache/ms-playwright/chromium-1243/chrome-linux64/chrome`, args: ["--no-sandbox", "--disable-gpu"], env: { ...process.env, LD_LIBRARY_PATH: `${process.env.HOME}/.local/chromelibs/root/usr/lib/x86_64-linux-gnu` } }); -const page = await (await browser.newContext({ viewport: { width: 1440, height: Number(process.env.HEIGHT || 900) } })).newPage(); -const errors = []; -page.on("pageerror", (error) => errors.push(String(error).slice(0, 200))); -await page.addInitScript(([l, th]) => { localStorage.setItem("agents-core-web.language", l); localStorage.setItem("agents-core-web.theme", th); }, [locale, THEME]); -await page.goto(`http://127.0.0.1:4274/#${view}`); -await page.waitForTimeout(2200); -for (const step of steps) { - const [kind, value] = [step.slice(0, step.indexOf("=")), step.slice(step.indexOf("=") + 1)]; - if (kind === "css") await page.locator(value).first().click(); - else if (kind === "text") await page.getByText(value, { exact: true }).first().click(); - else if (kind === "row") await page.locator("tbody tr").nth(Number(value)).click(); - await page.waitForTimeout(900); -} -await page.mouse.move(0, 0); -await page.screenshot({ path: outFile, fullPage: Boolean(process.env.FULL) }); -console.log(JSON.stringify(errors)); -await browser.close(); -process.exit(0); diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index f24d5aef9..90f471ce4 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -607,15 +607,15 @@ orbits hold their places, the grid is a still frame and no transition runs. ### Getting started The first card on the Overview while any step is to do: a card header ("Getting -started", "n of 3 done", a help tip, then a ghost Take the tour button and an icon -button that hides it) over three rows split by Faint Rules. Each row has a 22px +started", "n of 4 done", a help tip, then a ghost Take the tour button and an icon +button that hides it) over four rows split by Faint Rules. Each row has a 22px numbered ring (a check on the tile wash when done), a 13px/600 title over one 12.5px Graphite line, a status dot (Done in green, To do in Idle Gray, Checking pending, Unknown for a failed read) and one outline action while the step is to -do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Create project -(which continues to the new project's first key) or Issue key; Projects and keys. -Add node, Create project and Issue key open their page with the dialog already -open. Every step done turns it into one line, "You're set", with Take the tour and +do: Set up sandboxes, Add node, Open Nodes or Open sandbox backend; Open System; +Create project (which continues to the new project's first key) or Issue key; +Projects and keys. Add node, Create project and Issue key open their page with the +dialog already open; Open System focuses the default harness's Set or Replace. Every step done turns it into one line, "You're set", with Take the tour and Dismiss; it stays, through the tour, until dismissed, and the checklist does not come back on its own. The choice is kept per installation in the browser, also while the deployment cannot be read; Show Getting started, a quiet row above the @@ -644,14 +644,21 @@ offered as Current); another backend starts from its standard size and this console's Runtime, and E2B always needs its key again. Rules sit behind help tips. ### System page -Read-only facts in three sections, each saying where it changes. Installation: the -public address, API base URL, installation ID and source commit as a fact card. -Sandboxes: the shared sandbox configuration, with a "Change on the Nodes page" -text action in the section header. Startup settings: a line naming the config file -and the apply command as copyable chips, with when they were last applied, over a -table of each setting, its value and the services a change restarts. Sensitive -settings show only Configured or Not set; Default and Fixed after install are -neutral pills beside the value. +Four sections, each saying where it changes. Installation: the public address, API +base URL, installation ID and source commit as a fact card. Default model, the one +section changed here: one card per harness in an auto-fill grid, its header holding +the harness name and outline actions (Set, or Replace and Clear); fact rows give the +harness's read-only startup state (a status dot and a Default pill, its source behind +a help tip), then the provider's protocol, base URL, whether a key is configured, +token limits when set and the update time, or Not set. Set and Replace open one form +dialog; the key field is a required password input, never prefilled or shown and +forgotten when the form closes. Core's rejection stays in red inside the form; Clear +is a ConfirmDialog. Sandboxes: the shared sandbox configuration, with a "Change on +the Nodes page" text action in the section header. Startup settings: a line naming +the config file and the apply command as copyable chips, with when they were last +applied, over a table of each setting, its value and the services a change restarts. +Sensitive settings show only Configured or Not set; Default and Fixed after install +are neutral pills beside the value. ### Loading and motion The console has no spinners and no "Loading…" lines. Reads are cached (TanStack diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index f0690a6ff..9924e3cde 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -78,10 +78,12 @@ workbench. template build's), a review, and advanced settings with the complete form — then the node list with each node's capacity, host figures and allocations, enrollment, renaming, sandbox limits and removal; Add node - asks for the node's sandbox limits before it issues the one-time command), System (read-only: the - installation's public address, API base URL, installation ID and source commit; Core's - startup settings from config.json, with the file and the apply command that change them; - and the sandbox configuration every project shares, with a link to Nodes where it changes). + asks for the node's sandbox limits before it issues the one-time command), System (the + installation's public address, API base URL, installation ID and source commit, read-only; + each harness's default model, set, replaced or cleared there beside its read-only startup + state; the sandbox configuration every project shares, with a link to Nodes where it + changes; and Core's startup settings from config.json, with the file and the apply command + that change them). - A node whose provider is not ready names the reason (Docker unreachable, no Docker limits, missing Runtime image, no KVM, missing microsandbox components, a host too small) and its fix in the help tip beside its status, wherever that status shows. @@ -95,9 +97,10 @@ workbench. about when Core reclaims it. Docker never suspends and shows none of it. - **Getting started**: signing in opens the console on the Overview; nothing is forced first. While a step is to do, a Getting started checklist on the Overview - shows three steps, in any order, each with its state and one action: sandboxes + shows four steps, in any order, each with its state and one action: sandboxes ready (a saved deployment and a node online and ready, or a saved E2B deployment - whose template build is not reported as not ready), + whose template build is not reported as not ready), a default model on the default + harness (on any enabled harness when none is default), a project with an active key, and a first Session. Completion comes from reads the console already makes. It can be hidden; Show Getting started in the sidebar opens it again, and it ends with a brief "You're set". The optional diff --git a/apps/web/e2e/access.spec.ts b/apps/web/e2e/access.spec.ts index 208cffa44..34759e445 100644 --- a/apps/web/e2e/access.spec.ts +++ b/apps/web/e2e/access.spec.ts @@ -48,6 +48,7 @@ test("opens a fresh install on the Overview's Getting started: a project and its const step = (name: string) => page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: name }); // The fixture deployment already has a ready node. await expect(step("Get sandboxes ready")).toContainText("Done"); + await expect(step("Set a default model")).toContainText("To do"); await expect(step("Create a project and issue a key")).toContainText("To do"); await expect(step("Run the first Session")).toContainText("To do"); @@ -82,6 +83,25 @@ test("opens a fresh install on the Overview's Getting started: a project and its expect(stored).not.toContain(FIXTURE_CORE_KEY); }); +test("leads from Getting started to the default model, and counts it done once the default harness has one", async ({ page, request }) => { + await openConsole(page, request, "overview", { fresh: true }); + const step = page.getByRole("region", { name: "Getting started" }).getByRole("listitem").filter({ hasText: "Set a default model" }); + await expect(step).toContainText("To do"); + await step.getByRole("button", { name: "Open System" }).click(); + await expect(page.getByRole("heading", { name: "System", level: 1 })).toBeVisible(); + // It arrives on the default harness's action. + const set = page.getByRole("button", { name: "Set the default model for Codex" }); + await expect(set).toBeFocused(); + await set.click(); + const form = page.getByRole("dialog", { name: "Set default model for Codex" }); + await form.getByLabel("Base URL").fill("https://model.example/v1"); + await form.getByLabel("API key").fill("sk-fixture-getting-started"); + await form.getByRole("button", { name: "Save" }).click(); + await expect(form).toBeHidden(); + await page.getByRole("button", { name: "Overview", exact: true }).click(); + await expect(step).toContainText("Done"); +}); + test("shows every page empty on a fresh install, and Getting started hides, comes back and leads to sandbox setup", async ({ page, request }) => { await openConsole(page, request, "overview", { fresh: true, sandbox: "none", nodes: "none" }); // What each page shows once its reads are done: an empty state, Core's own figures, or sandbox setup. diff --git a/apps/web/e2e/console.ts b/apps/web/e2e/console.ts index d4253641a..5462ee833 100644 --- a/apps/web/e2e/console.ts +++ b/apps/web/e2e/console.ts @@ -11,13 +11,14 @@ export const FIXTURE_CORE_KEY = "fixture-core-key-3f9a2c71"; * `sandbox` the sandbox deployment, `nodes: "none"` a deployment no node has joined, and * `installation` how config.json's public_url is set: "public" (HTTPS, the default), "local" * (loopback: only the Core machine reaches the API, and E2B is rejected) or "stale" (public, - * with a node enrolled with an earlier address). + * with a node enrolled with an earlier address), and `credentials: "none"` a Core without a + * credential encryption key, which cannot store a model provider's key. */ -export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale" } +export interface FixtureOptions { fresh?: boolean; sandbox?: "configured" | "none" | "e2b"; nodes?: "none"; installation?: "public" | "local" | "stale"; credentials?: "none" } /** Fresh fixture state: signed out ("login") or already signed in ("authenticated"). */ export async function resetFixture(request: APIRequestContext, auth: "login" | "authenticated" = "authenticated", options: FixtureOptions = {}) { - await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}`); + await request.post(`${fixture}/__fixture/reset?auth=${auth}${options.fresh ? "&projects=none" : ""}&sandbox=${options.sandbox ?? "configured"}${options.nodes ? `&nodes=${options.nodes}` : ""}&installation=${options.installation ?? "public"}${options.credentials ? `&credentials=${options.credentials}` : ""}`); } /** Opens the console already signed in, in English. */ diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index fef5dcd09..ca6efe621 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -1,6 +1,6 @@ // Browser acceptance fixture: the console service's routes (/console/**) and the // Core management tree it forwards (/core/v1/**: installation, projects, summary, -// audit log, metrics and /core/v1/sandbox/**), with synthetic, deterministic data and +// audit log, metrics, harness default models and /core/v1/sandbox/**), with synthetic, deterministic data and // in-memory writes. It never serves /v1; any /v1 request, and any // browser-supplied Authorization header, is recorded so a test can assert that // the console stays on its management boundary. @@ -80,7 +80,7 @@ function e2bDeployment() { return { ...configuredDeployment(), provider: "e2b", mode: "direct", resources: { allocations: 3, pending: 1 }, specification: { resources: { cpus: 2, memory_mib: 2048 } }, e2b: { template: "parsar-runtime:0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b", credential_configured: true, template_build: templateBuild } }; } -function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public") { +function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "demo", address = "public", credentials = "configured") { const base = buildDemo(); const now = Math.floor(Date.now() / 1000); const resources = buildResources(now, base.agents, base.sessions); @@ -98,6 +98,15 @@ function reset(mode = "login", fresh = false, sandbox = "configured", nodes = "d executorCredentials: new Map(), // How config.json's public_url is set: "public", "local" or "stale". installation: address, + // "none": Core has no credential encryption key, so it cannot store a provider's key. + credentialKey: credentials !== "none", + // Startup state and deployment default model provider per harness; API keys are never kept. + // The demo deployment's default harness has a default model; a fresh install has none. + harnesses: { + claude_sdk: { enabled: true, default: false, provider: null }, + codex: { enabled: true, default: true, provider: fresh ? null : { object: "core.model_provider", harness: "codex", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, updated_at: new Date((now - 86400) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z") } }, + mcode: { enabled: false, default: false, provider: null }, + }, // "none": the deployment is not configured yet, so the Nodes page offers setup. deployment: null, }; @@ -112,11 +121,11 @@ function send(response, status, body, headers = {}) { response.writeHead(status, { "content-type": "application/json", "cache-control": "no-store", ...headers }); response.end(JSON.stringify(body)); } -function error(response, status, message, code = null, param = null) { +function error(response, status, message, code = null) { // Derive `type` as Core's writeError does (services/agents-api/internal/api/errors.go). const type = status >= 500 ? "server_error" : status === 409 ? "conflict_error" : code === "not_found_error" || code === "invalid_beta" ? code : "invalid_request_error"; - send(response, status, { error: { message, type, code, param } }); + send(response, status, { error: { message, type, code, param: null } }); } async function body(request) { const chunks = []; @@ -394,11 +403,82 @@ function registeredNode(nodeId) { return { id: nodeId, name: nodeId, provider: state.deployment?.provider ?? "docker", core_url: publicUrl(), online: false, provider_ready: false, cpu_count: null, available_memory_bytes: null, available_disk_bytes: null, running: 0, snapshots: 0, last_seen_at: null, ...limits, active: 0, reserved: 0, retained: 0, cleanup_pending: 0, created_at: new Date().toISOString() }; } +const HARNESS_PROVIDER = /^\/harnesses\/([^/]+)\/model-provider$/; +const PROVIDER_FIELDS = new Set(["protocol", "base_url", "api_key", "context_window", "max_output_tokens"]); +/** Each harness's protocol, as Core's registry declares it; only mcode requires token limits. */ +const HARNESS_PROTOCOL = { claude_sdk: "anthropic", codex: "responses", mcode: "anthropic" }; +/** Core's one message for a body that is not a complete provider; it never echoes a value. */ +const PROVIDER_SHAPE = "The body must be a complete model provider: protocol, base_url, api_key and optional nonnegative context_window and max_output_tokens."; +const httpsBase = (value) => { try { const url = new URL(value); return url.protocol === "https:" && Boolean(url.hostname) && !url.username && !url.password && !url.search && !url.hash; } catch { return false; } }; +/** An omitted limit, or a nonnegative integer that fits Core's int32. */ +const tokenLimit = (value) => value === undefined || (Number.isInteger(value) && value >= 0 && value <= 2 ** 31 - 1); +const jsonKind = (value) => Array.isArray(value) ? "an array" : typeof value === "string" ? "a string" : typeof value === "boolean" ? "a boolean" : Number.isInteger(value) ? "an integer" : "a number"; + +/** The first rule a provider body breaks, in Core's order and words; null when valid. */ +function providerProblem(harness, input) { + if (Object.keys(input).some((key) => !PROVIDER_FIELDS.has(key)) || ["protocol", "base_url", "api_key"].some((key) => typeof input[key] !== "string") || + !tokenLimit(input.context_window) || !tokenLimit(input.max_output_tokens)) return PROVIDER_SHAPE; + if (!httpsBase(input.base_url)) return "model provider requires an HTTPS base_url without credentials, query or fragment"; + if (input.protocol !== "anthropic" && input.protocol !== "responses") return "unsupported model provider protocol"; + if (!input.api_key.trim() || Buffer.byteLength(input.api_key) > 16384 || /[\0\r\n]/.test(input.api_key)) return "invalid model provider API key"; + if ((input.max_output_tokens ?? 0) > (input.context_window ?? 0)) return "invalid model token limits"; + if (input.protocol !== HARNESS_PROTOCOL[harness]) return "selected harness does not support this model provider protocol"; + if (harness === "mcode" && !(input.context_window > 0 && input.max_output_tokens > 0)) return "selected harness requires positive model context_window and max_output_tokens"; + return null; +} + +/** + * Harnesses and their deployment default model providers, as Core serves them + * (services/agents-api/internal/api/harness_model_providers.go): the list + * reflects every write; an unknown harness or an unset provider is 404; PUT + * takes a JSON object of at most 32 KiB, is a full replacement that needs the + * key every time (mcode also both limits) and answers with the safe view; + * DELETE is 204 and safe to repeat. A disabled harness may still be configured. + */ +async function harnessRoute(request, response, path) { + const view = (id) => ({ object: "core.harness", id, enabled: state.harnesses[id].enabled, default: state.harnesses[id].default, model_provider: state.harnesses[id].provider }); + if (path === "/harnesses" && request.method === "GET") return send(response, 200, { object: "list", data: Object.keys(state.harnesses).map(view) }); + const match = path.match(HARNESS_PROVIDER); + const harness = match && Object.hasOwn(state.harnesses, match[1]) ? match[1] : null; + if (!harness) return error(response, 404, "This harness does not exist.", "not_found"); + const entry = state.harnesses[harness]; + if (request.method === "GET") return entry.provider ? send(response, 200, entry.provider) : error(response, 404, "This harness has no deployment default model provider.", "not_found"); + if (request.method === "DELETE") { + entry.provider = null; + response.writeHead(204, { "cache-control": "no-store" }); + return response.end(); + } + if (request.method !== "PUT") return error(response, 405, "Method not allowed."); + // Core's shared JSON body gate, with this route's 32 KiB limit. + if (!/^application\/json\s*(;|$)/i.test(request.headers["content-type"] ?? "")) return error(response, 400, "expected request with Content-Type: application/json", "invalid_request_error"); + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + const raw = Buffer.concat(chunks); + if (raw.length > 32 * 1024) return error(response, 413, "Request exceeds 32 KiB.", "request_too_large"); + let input; + try { input = raw.length ? JSON.parse(raw.toString("utf8")) : null; } catch { + return error(response, 400, "Invalid body: failed to parse JSON value. Please check the value to ensure it is valid JSON. (Common errors include trailing commas, missing closing brackets, missing quotation marks, etc.)", "invalid_request_error"); + } + input ??= {}; + if (typeof input !== "object" || Array.isArray(input)) return error(response, 400, `Invalid type: expected an object, but got ${jsonKind(input)} instead.`, "invalid_request_error"); + const problem = providerProblem(harness, input); + if (problem) return error(response, 400, problem, "invalid_request_error"); + // As Core's error mapping: sealing the key needs the credential encryption key. + if (!state.credentialKey) return error(response, 503, "Credential encryption is not configured on this service.", "credential_storage_unavailable"); + entry.provider = { + object: "core.model_provider", harness, protocol: input.protocol, base_url: input.base_url, api_key_configured: true, + ...(input.context_window ? { context_window: input.context_window } : {}), + ...(input.max_output_tokens ? { max_output_tokens: input.max_output_tokens } : {}), + updated_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + }; + return send(response, 200, entry.provider); +} + /** Test controls: reset state, inject one failure, register or change a node, and read what the browser sent. */ async function fixtureRoute(request, response, url) { if (url.pathname === "/__fixture/health") return send(response, 200, { ok: true }); if (url.pathname === "/__fixture/reset" && request.method === "POST") { - reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public"); + reset(url.searchParams.get("auth") ?? "login", url.searchParams.get("projects") === "none", url.searchParams.get("sandbox") ?? "configured", url.searchParams.get("nodes") ?? "demo", url.searchParams.get("installation") ?? "public", url.searchParams.get("credentials") ?? "configured"); return send(response, 200, { ok: true }); } if (url.pathname === "/__fixture/fail-next" && request.method === "POST") { @@ -443,6 +523,7 @@ http.createServer(async (request, response) => { if (url.pathname.startsWith("/core/v1/sandbox/")) return await sandboxRoute(request, response, url.pathname.slice("/core/v1/sandbox".length)); if (url.pathname.startsWith("/core/v1/")) { const path = url.pathname.slice("/core/v1".length); + if (path === "/harnesses" || path.startsWith("/harnesses/")) return await harnessRoute(request, response, path); const credentials = path.match(EXECUTOR_CREDENTIALS); if (credentials) return await executorCredentialRoute(request, response, credentials[1], credentials[2], credentials[3]); return write ? await adminWrite(request, response, path) : adminRead(response, path, url); diff --git a/apps/web/e2e/system.spec.ts b/apps/web/e2e/system.spec.ts new file mode 100644 index 000000000..ba93b30c9 --- /dev/null +++ b/apps/web/e2e/system.spec.ts @@ -0,0 +1,116 @@ +import { expect, test } from "@playwright/test"; + +import { expectManagementBoundary, failNext, openConsole, writes } from "./console"; + +test.afterEach(async ({ request }) => expectManagementBoundary(request)); + +const KEY = "sk-fixture-default-model-canary"; + +test("sets, replaces and clears a harness's default model, and keeps its key out of the browser", async ({ page, request }) => { + // A fresh install: no harness has a default model yet. + await openConsole(page, request, "system", { fresh: true }); + const section = page.getByRole("region", { name: "Default model" }); + const codex = section.getByRole("article", { name: "Codex" }); + const mcode = section.getByRole("article", { name: "MiniMax Code" }); + await expect(codex).toContainText("Enabled"); + await expect(codex).toContainText("Default"); + await expect(codex).toContainText("Not set"); + await expect(section.getByRole("article", { name: "Claude SDK" })).toContainText("Not set"); + await expect(mcode).toContainText("Disabled"); + + await codex.getByRole("button", { name: "Set the default model for Codex" }).click(); + const set = page.getByRole("dialog", { name: "Set default model for Codex" }); + await set.getByLabel("Base URL").fill("https://model.example/v1"); + await set.getByLabel("API key").fill(KEY); + // Limits are Core's 32-bit whole numbers, and max output needs a context window at least as large. + await set.getByLabel("Max output tokens").fill("32000"); + await expect(set.getByText("Set a context window at least this large.")).toBeVisible(); + await set.getByLabel("Context window").fill("2147483648"); + await expect(set.getByText("Enter a whole number up to 2147483647.")).toBeVisible(); + await expect(set.getByRole("button", { name: "Save" })).toBeDisabled(); + await set.getByLabel("Context window").fill("200000"); + await set.getByRole("button", { name: "Save" }).click(); + await expect(set).toBeHidden(); + for (const text of ["OpenAI Responses", "https://model.example/v1", "Configured", "200,000", "32,000"]) await expect(codex).toContainText(text); + expect(await page.content()).not.toContain(KEY); + + // Replacing starts from the saved fields but never from the key; closing the form forgets a typed key. + await codex.getByRole("button", { name: "Replace the default model for Codex" }).click(); + let replace = page.getByRole("dialog", { name: "Replace default model for Codex" }); + await expect(replace.getByLabel("Base URL")).toHaveValue("https://model.example/v1"); + await expect(replace.getByLabel("API key")).toHaveValue(""); + await expect(replace.getByRole("button", { name: "Save" })).toBeDisabled(); + await replace.getByLabel("API key").fill(KEY); + await replace.getByRole("button", { name: "Cancel" }).click(); + expect(await page.content()).not.toContain(KEY); + await codex.getByRole("button", { name: "Replace the default model for Codex" }).click(); + replace = page.getByRole("dialog", { name: "Replace default model for Codex" }); + await expect(replace.getByLabel("API key")).toHaveValue(""); + await replace.getByLabel("Base URL").fill("https://model.example/v2"); + await replace.getByLabel("API key").fill(KEY); + // Enter saves. + await replace.getByLabel("API key").press("Enter"); + await expect(replace).toBeHidden(); + await expect(codex).toContainText("https://model.example/v2"); + + await codex.getByRole("button", { name: "Clear the default model for Codex" }).click(); + const confirm = page.getByRole("dialog", { name: "Clear default model" }); + await confirm.getByRole("button", { name: "Clear default model" }).click(); + await expect(confirm).toBeHidden(); + await expect(codex).toContainText("Not set"); + + // MiniMax Code needs both limits; the form shows Core's reason. A disabled harness may still be configured. + await mcode.getByRole("button", { name: "Set the default model for MiniMax Code" }).click(); + const limits = page.getByRole("dialog", { name: "Set default model for MiniMax Code" }); + await limits.getByLabel("Base URL").fill("https://model.example/anthropic"); + await limits.getByLabel("API key").fill(KEY); + await limits.getByRole("button", { name: "Save" }).click(); + await expect(limits.getByRole("alert")).toHaveText("selected harness requires positive model context_window and max_output_tokens"); + await limits.getByRole("button", { name: "Cancel" }).click(); + await expect(mcode).toContainText("Not set"); + + expect(await writes(request)).toEqual([ + "PUT /core/v1/harnesses/codex/model-provider", + "PUT /core/v1/harnesses/codex/model-provider", + "DELETE /core/v1/harnesses/codex/model-provider", + "PUT /core/v1/harnesses/mcode/model-provider", + ]); + const browserState = await page.evaluate(() => JSON.stringify({ local: { ...localStorage }, session: { ...sessionStorage }, url: location.href, cookie: document.cookie })); + expect(browserState).not.toContain(KEY); +}); + +test("reports a Core without a credential key as a configuration error, without rereading", async ({ page, request }) => { + await openConsole(page, request, "system", { fresh: true, credentials: "none" }); + const codex = page.getByRole("region", { name: "Default model" }).getByRole("article", { name: "Codex" }); + await codex.getByRole("button", { name: "Set the default model for Codex" }).click(); + const set = page.getByRole("dialog", { name: "Set default model for Codex" }); + await set.getByLabel("Base URL").fill("https://model.example/v1"); + await set.getByLabel("API key").fill(KEY); + const reads: string[] = []; + page.on("request", (sent) => { if (sent.method() === "GET" && new URL(sent.url()).pathname === "/core/v1/harnesses") reads.push(sent.url()); }); + await set.getByRole("button", { name: "Save" }).click(); + await expect(set.getByRole("alert")).toHaveText("Core has no credential encryption key configured, so it can't store keys. Installer-based installs configure this automatically; for manual deployments, set AGENTS_API_CREDENTIAL_KEY_FILE for Core."); + await expect(set.getByRole("button", { name: "Save" })).toBeEnabled(); + expect(reads).toEqual([]); + expect(await writes(request)).toEqual(["PUT /core/v1/harnesses/codex/model-provider"]); + await set.getByRole("button", { name: "Cancel" }).click(); + await expect(codex).toContainText("Not set"); +}); + +test("reports an unconfirmed save, reads the default models again once and never repeats the write", async ({ page, request }) => { + await openConsole(page, request, "system", { fresh: true }); + const codex = page.getByRole("region", { name: "Default model" }).getByRole("article", { name: "Codex" }); + await codex.getByRole("button", { name: "Set the default model for Codex" }).click(); + const set = page.getByRole("dialog", { name: "Set default model for Codex" }); + await set.getByLabel("Base URL").fill("https://model.example/v1"); + await set.getByLabel("API key").fill(KEY); + await failNext(request, { method: "PUT", path: "/harnesses/codex/model-provider", status: 500 }); + const reads: string[] = []; + page.on("request", (sent) => { if (sent.method() === "GET" && new URL(sent.url()).pathname === "/core/v1/harnesses") reads.push(sent.url()); }); + await set.getByRole("button", { name: "Save" }).click(); + await expect(set.getByRole("alert")).toHaveText("Core did not confirm the change. The default models were read again; check them before trying again."); + await expect.poll(() => reads.length).toBe(1); + await page.waitForTimeout(500); + expect(reads).toHaveLength(1); + expect(await writes(request)).toEqual(["PUT /core/v1/harnesses/codex/model-provider"]); +}); diff --git a/apps/web/src/features/agents/AgentsPage.tsx b/apps/web/src/features/agents/AgentsPage.tsx index 44be05396..36496e2be 100644 --- a/apps/web/src/features/agents/AgentsPage.tsx +++ b/apps/web/src/features/agents/AgentsPage.tsx @@ -1,4 +1,4 @@ -import type { CoreHarnessKind, ModelProviderView, SavedAgent } from "@agents-core-web/agents-client"; +import type { SavedAgent } from "@agents-core-web/agents-client"; import { ArrowLeft, Bot, ListTree, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -10,6 +10,7 @@ import { CopyableId, ListToolbar, listSummary, NameCell, RowActions, SearchField import { useConsoleNavigation } from "../../lib/console-navigation"; import { useDeleteFlow } from "../../lib/delete-flow"; import { formatCompact, formatDateTime, formatInteger, formatPercent, formatRelative, MISSING } from "../../lib/format"; +import { harnessNames, protocolNames } from "../../lib/harness-labels"; import { admin, CreatorCell, CreatorHeading, forgetCreators, ProjectFilter, ProjectName, projectClient, readAllPages, useCreators, useProjectCollection, useProjects, type Owned } from "../../lib/projects"; import "./AgentCatalog.css"; import { type ProjectSummary } from "../../lib/admin-view"; @@ -18,14 +19,6 @@ import { collections } from "../../lib/queries"; import { DetailSkeleton, TableSkeleton } from "../../components/Skeleton"; import { useAgentDetail } from "../resources/detail-queries"; -export function harnessLabel(harness: CoreHarnessKind): string { - if (harness === "claude_sdk") return "Claude SDK"; - if (harness === "mcode") return "MiniMax Code"; - return "Codex"; -} - -/** Provider protocols by their product names, the same in every language. */ -const protocolNames: Record = { anthropic: "Anthropic Messages", responses: "OpenAI Responses" }; function coverage(summary: ProjectSummary | undefined, locale?: string): string { if (!summary || summary.coverage.sessions === 0) return MISSING; @@ -116,7 +109,7 @@ function AgentsList() { {showProject ? : null} {agent.model} - {harness ? harnessLabel(harness) : t("view.coreDefault")} + {harness ? harnessNames[harness] : t("view.coreDefault")} {formatInteger(agent.tools.length, locale)} {summary?.last_active_at ? formatRelative(summary.last_active_at, now, locale) : MISSING} @@ -238,7 +231,7 @@ function AgentDetail({ projectId, agentId }: { projectId: string; agentId: strin
{t("view.id")}
{tCommon("project.column")}
{t("view.columns.model")}
{agent.model}
-
{t("view.columns.harness")}
{agent.x_agents_core?.harness ? harnessLabel(agent.x_agents_core.harness) : t("view.coreDefault")}
+
{t("view.columns.harness")}
{agent.x_agents_core?.harness ? harnessNames[agent.x_agents_core.harness] : t("view.coreDefault")}
{t("view.created")}
{formatDateTime(agent.created_at, locale)}
{t("view.updated")}
{formatDateTime(agent.updated_at, locale)}
diff --git a/apps/web/src/features/overview/GettingStarted.tsx b/apps/web/src/features/overview/GettingStarted.tsx index 0fd18c6e8..6b73a42c6 100644 --- a/apps/web/src/features/overview/GettingStarted.tsx +++ b/apps/web/src/features/overview/GettingStarted.tsx @@ -8,6 +8,7 @@ import { useConsoleIntent, useConsoleNavigation } from "../../lib/console-naviga import { projectsQuery } from "../../lib/queries"; import { type FleetState } from "../fleet/use-sandbox-fleet"; import { useConsoleTour } from "../onboarding/ConsoleTour"; +import { harnessesQuery } from "../system/harness-queries"; import { checklistStorageKey, checklistView, @@ -22,8 +23,8 @@ import { } from "./getting-started"; /** - * Getting started: sandboxes, a project key and the first Session, each with - * its state and one action, in any order. It shows until every step is done + * Getting started: sandboxes, a default model, a project key and the first + * Session, each with its state and one action, in any order. It shows until every step is done * or it is hidden; Show Getting started in the sidebar opens it again. The * optional console tour opens from it. */ @@ -32,8 +33,13 @@ export function GettingStarted({ fleet, sessions }: { fleet: FleetState; session const { navigate } = useConsoleNavigation(); const openTour = useConsoleTour(); const projects = useQuery(projectsQuery); - const steps = gettingStartedSteps({ fleet, projects: projects.data ?? (projects.isError ? "failed" : undefined), sessions }); - const states = [steps.sandboxes.state, steps.key.state, steps.session]; + const harnesses = useQuery(harnessesQuery); + const steps = gettingStartedSteps({ + fleet, sessions, + projects: projects.data ?? (projects.isError ? "failed" : undefined), + harnesses: harnesses.data?.data ?? (harnesses.isError ? "failed" : undefined), + }); + const states = [steps.sandboxes.state, steps.model, steps.key.state, steps.session]; const allDone = states.every((state) => state === "done"); // Remembered per installation; a choice made here overrides what was read. @@ -134,8 +140,9 @@ export function GettingStarted({ fleet, sessions }: { fleet: FleetState; session
    - - navigate("projects") }} /> + navigate("system", {}, "default-model") }} /> + + navigate("projects") }} />
); diff --git a/apps/web/src/features/overview/getting-started.test.ts b/apps/web/src/features/overview/getting-started.test.ts index 365efbb60..c0d0db4ea 100644 --- a/apps/web/src/features/overview/getting-started.test.ts +++ b/apps/web/src/features/overview/getting-started.test.ts @@ -1,4 +1,4 @@ -import type { SandboxDeployment } from "@agents-core-web/agents-client"; +import type { CoreHarness, SandboxDeployment } from "@agents-core-web/agents-client"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { FleetState } from "../fleet/use-sandbox-fleet"; @@ -10,7 +10,9 @@ const deployment = (overrides: Partial = {}): SandboxDeployme resources: { allocations: 0, pending: 0 }, suspension: null, ...overrides, }); const fleet = (value: SandboxDeployment, nodes = [node("n1")]): FleetState => ({ status: "ready", snapshot: { deployment: value, nodes, allocations: [], loadedAt: 0 }, refreshing: false, error: null }); -const sandboxes = (state: FleetState) => gettingStartedSteps({ fleet: state, projects: [], sessions: 0 }).sandboxes; +const sandboxes = (state: FleetState) => gettingStartedSteps({ fleet: state, projects: [], sessions: 0, harnesses: [] }).sandboxes; +const provider = { object: "core.model_provider", protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true, updated_at: "2026-09-25T00:00:00Z" } as const; +const harness = (id: CoreHarness["id"], fields: Partial = {}): CoreHarness => ({ object: "core.harness", id, enabled: true, default: false, model_provider: null, ...fields }); describe("Getting started steps", () => { it("counts sandboxes ready with a saved deployment and a ready node, or a saved E2B deployment whose build is not reported unready", () => { @@ -28,7 +30,7 @@ describe("Getting started steps", () => { }); it("needs an active project with an active key, and any Session", () => { - const steps = (projects: Parameters[0]["projects"], sessions: number | "failed" | null = 0) => gettingStartedSteps({ fleet: { status: "loading" }, projects, sessions }); + const steps = (projects: Parameters[0]["projects"], sessions: number | "failed" | null = 0) => gettingStartedSteps({ fleet: { status: "loading" }, projects, sessions, harnesses: undefined }); expect(steps([]).key).toEqual({ state: "todo", project: null }); const older = project("p1", { active_key_count: 0, created_at: 1 }); const newer = project("p2", { active_key_count: 0, created_at: 2 }); @@ -38,6 +40,17 @@ describe("Getting started steps", () => { expect(steps("failed").key.state).toBe("unknown"); expect([steps([], 0).session, steps([], 2).session, steps([], null).session, steps([], "failed").session]).toEqual(["todo", "done", null, "unknown"]); }); + + it("needs a default model on the default harness, or on any enabled harness when none is default", () => { + const model = (harnesses: Parameters[0]["harnesses"]) => gettingStartedSteps({ fleet: { status: "loading" }, projects: undefined, sessions: null, harnesses }).model; + const on = (id: CoreHarness["id"]) => ({ ...provider, harness: id }); + expect(model([harness("codex", { default: true }), harness("claude_sdk", { model_provider: on("claude_sdk") })])).toBe("todo"); + expect(model([harness("codex", { default: true, model_provider: on("codex") })])).toBe("done"); + expect(model([harness("codex"), harness("mcode", { enabled: false, model_provider: on("mcode") })])).toBe("todo"); + expect(model([harness("codex"), harness("claude_sdk", { model_provider: on("claude_sdk") })])).toBe("done"); + expect(model(undefined)).toBeNull(); + expect(model("failed")).toBe("unknown"); + }); }); describe("Getting started visibility", () => { @@ -50,6 +63,8 @@ describe("Getting started visibility", () => { expect(checklistView(["done", "done", "done"], null)).toBe("hidden"); expect(checklistView(["done", "done", "done"], "open")).toBe("complete"); expect(checklistView(["todo", "todo", "todo"], "closed")).toBe("hidden"); + // A step added later does not reopen a checklist that was closed, by hand or after You're set. + expect(checklistView(["done", "todo", "done", "done"], "closed")).toBe("hidden"); }); afterEach(() => vi.unstubAllGlobals()); diff --git a/apps/web/src/features/overview/getting-started.ts b/apps/web/src/features/overview/getting-started.ts index 324a392c1..fc9f8fdb3 100644 --- a/apps/web/src/features/overview/getting-started.ts +++ b/apps/web/src/features/overview/getting-started.ts @@ -1,11 +1,14 @@ +import type { CoreHarness } from "@agents-core-web/agents-client"; + import { type Project } from "../../lib/admin-view"; import { type FleetState } from "../fleet/use-sandbox-fleet"; import { templateBuildStatus } from "../sandbox/deployment-specification"; /** - * Getting started on the Overview: three steps to a working deployment, - * computed only from reads the Overview already makes. A step whose read is - * pending is null, and "unknown" when that read failed; neither counts as done. + * Getting started on the Overview: four steps to a working deployment, + * computed from the Overview's reads and the harness list. A step whose read + * is pending is null, and "unknown" when that read failed; neither counts as + * done. */ export type StepState = "done" | "todo" | "unknown" | null; @@ -14,6 +17,7 @@ export type SandboxAction = "setup" | "add-node" | "nodes"; export interface GettingStartedSteps { sandboxes: { state: StepState; action: SandboxAction; cloud: boolean }; + model: StepState; /** `project` is the active project a key would be issued for; null means create one first. */ key: { state: StepState; project: Project | null }; session: StepState; @@ -25,10 +29,13 @@ export function gettingStartedSteps(input: { projects: readonly Project[] | "failed" | undefined; /** Sessions in every project, by Core's summary; null until it is read. */ sessions: number | "failed" | null; + /** Core's harnesses; undefined until they are read. */ + harnesses: readonly CoreHarness[] | "failed" | undefined; }): GettingStartedSteps { const { sessions } = input; return { sandboxes: sandboxStep(input.fleet), + model: modelStep(input.harnesses), key: keyStep(input.projects), session: sessions === null ? null : sessions === "failed" ? "unknown" : sessions > 0 ? "done" : "todo", }; @@ -54,6 +61,17 @@ function sandboxStep(fleet: FleetState): GettingStartedSteps["sandboxes"] { return { state: "todo", action: nodes.length ? "nodes" : "add-node", cloud: false }; } +/** + * Done once the default harness has a deployment default model provider; + * without a default harness, once any enabled harness has one. + */ +function modelStep(harnesses: readonly CoreHarness[] | "failed" | undefined): StepState { + if (!harnesses || harnesses === "failed") return harnesses ? "unknown" : null; + const target = harnesses.find((harness) => harness.default); + const set = target ? target.model_provider !== null : harnesses.some((harness) => harness.enabled && harness.model_provider !== null); + return set ? "done" : "todo"; +} + function keyStep(projects: readonly Project[] | "failed" | undefined): GettingStartedSteps["key"] { if (!projects || projects === "failed") return { state: projects ? "unknown" : null, project: null }; const active = projects.filter((project) => project.status === "active"); diff --git a/apps/web/src/features/system/DefaultModelsSection.tsx b/apps/web/src/features/system/DefaultModelsSection.tsx new file mode 100644 index 000000000..b33168fe2 --- /dev/null +++ b/apps/web/src/features/system/DefaultModelsSection.tsx @@ -0,0 +1,329 @@ +import { AgentCoreError, type CoreHarness, type CoreHarnessKind, type ModelProviderInput } from "@agents-core-web/agents-client"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useCallback, useId, useRef, useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { ConfirmDialog } from "../../components/ConfirmDialog"; +import { EmptyState, HelpTip, Section, StatusDot } from "../../components/console-ui"; +import { ErrorState } from "../../components/ErrorState"; +import { Modal } from "../../components/Modal"; +import { TableSkeleton } from "../../components/Skeleton"; +import { failedLast, useFailureToast, useToast } from "../../components/Toast"; +import { useDeleteFlow } from "../../lib/delete-flow"; +import { formatDateTime, formatInteger } from "../../lib/format"; +import { useConsoleIntent } from "../../lib/console-navigation"; +import { harnessNames, protocolNames } from "../../lib/harness-labels"; +import { admin } from "../../lib/projects"; +import { Fact } from "./Fact"; +import { harnessesQuery } from "./harness-queries"; + +/** A write that gets no answer in this time has an unknown outcome. */ +const WRITE_TIMEOUT_MS = 30_000; + +type Protocol = ModelProviderInput["protocol"]; +/** The one protocol Core accepts for each harness; the form sends it and cannot change it. */ +const harnessProtocol: Record = { codex: "responses", claude_sdk: "anthropic", mcode: "anthropic" }; +/** Core stores both token limits as 32-bit integers. */ +const INT32_MAX = 2_147_483_647; + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error ?? ""); +} + +/** A token limit: empty is omitted; anything but a whole number within Core's range is a problem. */ +function tokenLimit(text: string): { value: number | undefined; problem: "whole" | "large" | null } { + const value = text.trim(); + if (!value) return { value: undefined, problem: null }; + if (!/^\d+$/u.test(value)) return { value: undefined, problem: "whole" }; + const number = Number(value); + return number > INT32_MAX ? { value: undefined, problem: "large" } : { value: number, problem: null }; +} + +function isUrl(value: string): boolean { + try { + new URL(value); + return true; + } catch { + return false; + } +} + +/** + * System › Default model: each harness's deployment default model provider. + * It applies to Core-hosted Sessions, after a provider in the request or on + * the Agent, and is the only source for Sessions without an environment; + * self-hosted Sessions bring their own. Whether a harness is enabled, and + * which is the default, is Core's startup configuration and only shown here. + * A write replaces the whole provider and needs the API key every time; the + * key lives only in the open form's state, never in the query cache, storage + * or the URL. Writes are never retried automatically; after each one the + * harnesses are read again. + */ +export function DefaultModelsSection() { + const { t } = useTranslation("system"); + const toast = useToast(); + const queryClient = useQueryClient(); + const query = useQuery(harnessesQuery); + const harnesses = query.data?.data ?? null; + const reread = useCallback(() => { void queryClient.invalidateQueries({ queryKey: harnessesQuery.queryKey }); }, [queryClient]); + useFailureToast(harnesses && failedLast(query) ? message(query.error) : null, t("models.refreshFailed"), "harnesses-read"); + // From Getting started: bring the default harness's Set or Replace into view and focus. + useConsoleIntent("default-model", harnesses ? "ready" : query.isError ? "unavailable" : "wait", () => { + window.requestAnimationFrame(() => { + const section = document.getElementById("system-models-heading")?.closest("section"); + const action = section?.querySelector("article[data-default] .system-model-actions button") ?? section?.querySelector(".system-model-actions button"); + section?.scrollIntoView({ block: "start" }); + action?.focus(); + }); + }); + + // The harness being edited, read from the latest list so a reread updates the open form's title. + const [editing, setEditing] = useState(null); + const editingHarness = (editing && harnesses?.find((harness) => harness.id === editing)) || null; + const clear = useDeleteFlow( + async (harness) => { + await admin.deleteHarnessModelProvider(harness, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); + toast.show(t("models.cleared", { harness: harnessNames[harness] }), { tone: "success" }); + }, + reread, + { uncertain: t("models.clearDialog.uncertain") }, + ); + + let body: ReactNode; + if (!harnesses) { + body = query.isError && !query.isFetching + ? { void query.refetch(); }} /> + : ; + } else if (!harnesses.length) { + body = ; + } else { + body = ( +
+ {harnesses.map((harness) => ( + setEditing(harness.id)} onClear={() => clear.ask(harness.id)} /> + ))} +
+ ); + } + + return ( +
+ {body} + setEditing(null)} + onReread={reread} + onSaved={(harness) => { + setEditing(null); + reread(); + toast.show(t("models.saved", { harness: harnessNames[harness] }), { tone: "success" }); + }} + /> + void clear.confirm()} + onClose={clear.cancel} + > + {clear.target ? ( + <> +

{t("models.clearDialog.prompt", { harness: harnessNames[clear.target] })}

+

{t("models.clearDialog.consequence")}

+ + ) : null} +
+
+ ); +} + +function HarnessCard({ harness, busy, onEdit, onClear }: { harness: CoreHarness; busy: boolean; onEdit: () => void; onClear: () => void }) { + const { t, i18n } = useTranslation("system"); + const locale = i18n.resolvedLanguage; + const headingId = useId(); + const name = harnessNames[harness.id]; + const provider = harness.model_provider; + return ( +
+
+

{name}

+
+ {/* A disabled harness may still be configured; Core keeps the provider until it is enabled. */} + + {provider ? ( + + ) : null} +
+
+
+ + + + {harness.default ? {t("models.default")} : null} + + + {provider ? <> + {protocolNames[provider.protocol]} + {provider.base_url} + {provider.api_key_configured ? t("models.keyConfigured") : t("models.keyNotConfigured")} + {provider.context_window !== undefined ? {formatInteger(provider.context_window, locale)} : null} + {provider.max_output_tokens !== undefined ? {formatInteger(provider.max_output_tokens, locale)} : null} + {formatDateTime(Math.floor(Date.parse(provider.updated_at) / 1000), locale)} + : ( + {t("models.notSet")} + )} +
+
+ ); +} + +/** + * Sets or replaces one harness's provider. Non-secret fields start from the + * current provider; the API key never does. The protocol is the one Core + * accepts for the harness. The form checks that the base URL is a URL and the + * limits are whole numbers within Core's range, with max output no larger than + * the context window; Core's other rules come back as its 400 message, shown + * beside the form. Enter saves; a save in flight blocks another. + */ +function ModelProviderDialog({ harness, onClose, onSaved, onReread }: { + harness: CoreHarness | null; + onClose: () => void; + onSaved: (harness: CoreHarnessKind) => void; + onReread: () => void; +}) { + const { t } = useTranslation("system"); + const { t: tCommon } = useTranslation(); + const id = useId(); + const formId = `${id}-form`; + const current = harness?.model_provider ?? null; + const [baseUrl, setBaseUrl] = useState(current?.base_url ?? ""); + const [apiKey, setApiKey] = useState(""); + const [contextWindow, setContextWindow] = useState(current?.context_window === undefined ? "" : String(current.context_window)); + const [maxOutputTokens, setMaxOutputTokens] = useState(current?.max_output_tokens === undefined ? "" : String(current.max_output_tokens)); + const [busy, setBusy] = useState(false); + const saving = useRef(false); + const [error, setError] = useState(null); + + const name = harness ? harnessNames[harness.id] : ""; + const protocol = harness ? harnessProtocol[harness.id] : "responses"; + const limitsRequired = harness?.id === "mcode"; + const url = baseUrl.trim(); + const urlProblem = url && !isUrl(url) ? t("models.form.baseUrlInvalid") : null; + const context = tokenLimit(contextWindow); + const output = tokenLimit(maxOutputTokens); + const limitProblem = (limit: ReturnType) => (limit.problem === "whole" ? t("models.form.wholeNumber") : limit.problem === "large" ? t("models.form.tooLarge") : null); + const contextProblem = limitProblem(context); + // Core rejects max output above the context window, an omitted window counting as 0. + const outputProblem = limitProblem(output) ?? (!contextProblem && output.value !== undefined && output.value > (context.value ?? 0) ? t("models.form.needsContext") : null); + const ready = harness !== null && !busy && url !== "" && !urlProblem && apiKey.trim() !== "" && !contextProblem && !outputProblem; + + async function save() { + if (!ready || !harness || saving.current) return; + saving.current = true; + setBusy(true); + setError(null); + try { + await admin.setHarnessModelProvider(harness.id, { + protocol, base_url: url, api_key: apiKey.trim(), + ...(context.value === undefined ? {} : { context_window: context.value }), + ...(output.value === undefined ? {} : { max_output_tokens: output.value }), + }, { signal: AbortSignal.timeout(WRITE_TIMEOUT_MS) }); + onSaved(harness.id); + } catch (caught) { + // Never retried: a rejection shows Core's reason; an unknown outcome is read again first. + if (caught instanceof AgentCoreError && caught.status >= 400 && caught.status < 500 && caught.status !== 408) { + setError(caught.message); + } else if (caught instanceof AgentCoreError && caught.code === "credential_storage_unavailable") { + // A deployment without a credential key stores nothing: a configuration error, not an unknown outcome. + setError(t("models.form.noCredentialKey")); + } else { + setError(t("models.form.uncertain")); + onReread(); + } + } finally { + saving.current = false; + setBusy(false); + } + } + + const limitField = (field: "context" | "output", value: string, setValue: (value: string) => void, problem: string | null) => { + const inputId = `${id}-${field}`; + return ( +
+ + + {t(`models.form.${field}Help${limitsRequired ? "Required" : ""}`)} + + setValue(event.target.value)} + aria-required={limitsRequired} + aria-invalid={problem ? true : undefined} + aria-describedby={`${inputId}-help${problem ? ` ${inputId}-problem` : ""}`} + /> + {problem ? {problem} : null} +
+ ); + }; + + return ( + { if (!busy) onClose(); }} + footer={( + <> + + + + )} + > +
{ event.preventDefault(); void save(); }}> +
+ + {t("models.protocol")} + {t("models.form.protocolHelp")} + +

{protocolNames[protocol]}

+
+
+ + setBaseUrl(event.target.value)} + autoComplete="off" + spellCheck={false} + aria-invalid={urlProblem ? true : undefined} + aria-describedby={urlProblem ? `${id}-url-problem` : undefined} + /> + {urlProblem ? {urlProblem} : null} +
+
+ + + {t("models.form.apiKeyHelp")} + + setApiKey(event.target.value)} aria-required="true" aria-describedby={`${id}-key-help`} /> +
+
+ {limitField("context", contextWindow, setContextWindow, contextProblem)} + {limitField("output", maxOutputTokens, setMaxOutputTokens, outputProblem)} +
+ {error ?

{error}

: null} +
+
+ ); +} diff --git a/apps/web/src/features/system/Fact.tsx b/apps/web/src/features/system/Fact.tsx new file mode 100644 index 000000000..47da25aef --- /dev/null +++ b/apps/web/src/features/system/Fact.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +import { HelpTip } from "../../components/console-ui"; + +/** One label/value row of a System page ledger. */ +export function Fact({ label, help, children }: { label: string; help?: string; children: ReactNode }) { + return ( +
+
+ {label} + {help ? {help} : null} +
+
{children}
+
+ ); +} diff --git a/apps/web/src/features/system/SystemPage.tsx b/apps/web/src/features/system/SystemPage.tsx index dcff96a80..c36757b7f 100644 --- a/apps/web/src/features/system/SystemPage.tsx +++ b/apps/web/src/features/system/SystemPage.tsx @@ -4,7 +4,7 @@ import { Server } from "lucide-react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; -import { EmptyState, HelpTip, PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; +import { EmptyState, PageBody, PageHeader, RefreshButton, Section, StatusDot } from "../../components/console-ui"; import { ErrorState } from "../../components/ErrorState"; import { CopyableId } from "../../components/list-ui"; import { TableSkeleton } from "../../components/Skeleton"; @@ -13,6 +13,9 @@ import { formatBytes, formatPeriod } from "../../lib/format"; import { installationQuery } from "../../lib/installation"; import { sandboxSize, templateBuildSize, templateBuildStatus } from "../sandbox/deployment-specification"; import { sandboxDeploymentQuery } from "../sandbox/sandbox-queries"; +import { DefaultModelsSection } from "./DefaultModelsSection"; +import { Fact } from "./Fact"; +import { harnessesQuery } from "./harness-queries"; import { StartupSettings } from "./StartupSettings"; import "./system.css"; @@ -33,24 +36,14 @@ function FactsSkeleton({ facts }: { facts: number }) { ); } -function Fact({ label, help, children }: { label: string; help?: string; children: ReactNode }) { - return ( -
-
- {label} - {help ? {help} : null} -
-
{children}
-
- ); -} - /** - * Platform › System: this installation's addresses, the sandbox configuration - * every project shares — where sandboxes run, how big each one is, the Runtime - * or E2B template build they run, microsandbox's idle suspension and - * maintenance — and Core's startup settings. Each says where it is changed: - * sandboxes on the Nodes page, startup settings in config.json. + * Platform › System: this installation's addresses; each harness's default + * model for Core-hosted Sessions and Sessions without an environment, set + * here; the sandbox configuration every project shares — where sandboxes run, + * how big each one is, the Runtime or E2B template build they run, + * microsandbox's idle suspension and maintenance — and Core's startup + * settings. Each says where it is changed: sandboxes on the Nodes page, + * startup settings in config.json. */ export function SystemPage() { const { t, i18n } = useTranslation("system"); @@ -59,8 +52,9 @@ export function SystemPage() { const { navigate } = useConsoleNavigation(); const deployment = useQuery(sandboxDeploymentQuery); const installation = useQuery(installationQuery); + const harnesses = useQuery(harnessesQuery); const data = deployment.data ?? null; - const refresh = () => { void deployment.refetch(); void installation.refetch(); }; + const refresh = () => { void deployment.refetch(); void installation.refetch(); void harnesses.refetch(); }; const spec = data?.specification ?? null; // An E2B selection may adopt its template build's size instead of saving one. const each = data ? sandboxSize(data) : null; @@ -135,7 +129,8 @@ export function SystemPage() { ? void installation.refetch()} /> : ; // One announcement while either first read is still out; each slot shows only its own placeholder. - const reading = (!data && !(deployment.isError && !deployment.isFetching)) || (!about && !(installation.isError && !installation.isFetching)); + const reading = (!data && !(deployment.isError && !deployment.isFetching)) || (!about && !(installation.isError && !installation.isFetching)) || + (!harnesses.data && !(harnesses.isError && !harnesses.isFetching)); return (
@@ -143,11 +138,12 @@ export function SystemPage() { headingId="system-heading" title={tNav("views.system")} help={t("help")} - actions={} + actions={} /> {reading ?

{t("loading")}

: null} {facts} + {body} {about ? : installation.isError ? null : }
diff --git a/apps/web/src/features/system/harness-queries.ts b/apps/web/src/features/system/harness-queries.ts new file mode 100644 index 000000000..5a66ab6df --- /dev/null +++ b/apps/web/src/features/system/harness-queries.ts @@ -0,0 +1,13 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { admin } from "../../lib/projects"; + +/** + * The harnesses and their deployment default model providers + * (`/core/v1/harnesses`). The cache holds only Core's safe view; an API key + * being written never enters it. + */ +export const harnessesQuery = queryOptions({ + queryKey: ["harnesses"], + queryFn: ({ signal }) => admin.listHarnesses({ signal }), +}); diff --git a/apps/web/src/features/system/system.css b/apps/web/src/features/system/system.css index 90bd450fb..f2033cb28 100644 --- a/apps/web/src/features/system/system.css +++ b/apps/web/src/features/system/system.css @@ -110,3 +110,74 @@ align-items: center; gap: 4px 6px; } + +/* Default model: one card per harness, its startup state and provider facts. */ +.system-models { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + align-items: start; + gap: 16px; +} + +.system-model { + display: flex; + min-width: 0; + flex-direction: column; + overflow: hidden; + background: var(--surface); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); +} + +.system-model-header { + display: flex; + min-height: 48px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--grid-line); +} + +.system-model-header h3 { + margin: 0; + color: var(--fg); + font-size: 13px; + font-weight: 600; + line-height: 18px; +} + +.system-model-actions { + display: flex; + flex-shrink: 0; + gap: 6px; +} + +.system-model-facts { + margin: 0; +} + +.system-model-facts .system-fact { + grid-template-columns: 136px minmax(0, 1fr); +} + +.system-model-state { + display: inline-flex; + align-items: center; + gap: 2px; +} + +/* The two token limits side by side in the provider form. */ +.system-model-limits { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +/* The fixed protocol in the provider form: a value, not a control. */ +.system-model-protocol { + margin: 0; + color: var(--fg); + font-size: 13px; + line-height: 18px; +} diff --git a/apps/web/src/i18n/locales/en/overview.ts b/apps/web/src/i18n/locales/en/overview.ts index 3df0fd333..2d605e475 100644 --- a/apps/web/src/i18n/locales/en/overview.ts +++ b/apps/web/src/i18n/locales/en/overview.ts @@ -3,7 +3,7 @@ export const overview = { description: "Health, capacity, usage and failures across every project of this Parsar Core deployment.", gettingStarted: { title: "Getting started", - help: "Three steps to a working deployment, in any order.", + help: "Four steps to a working deployment, in any order.", progress: "{{done}} of {{total}} done", tour: "Take the tour", dismiss: "Hide Getting started", @@ -22,6 +22,11 @@ export const overview = { nodes: "Open Nodes", backend: "Open sandbox backend", }, + model: { + title: "Set a default model", + body: "Core-hosted Sessions and Sessions without an environment need a model provider. Apps can also pass one in each request or save one on the Agent; this only sets the default.", + open: "Open System", + }, key: { title: "Create a project and issue a key", body: "Applications call Core with a project API key.", @@ -35,7 +40,7 @@ export const overview = { }, complete: { title: "You're set", - body: "Sandboxes are ready, a project has a key and the first Session ran.", + body: "Sandboxes are ready, a default model is set, a project has a key and the first Session ran.", dismiss: "Dismiss", }, }, diff --git a/apps/web/src/i18n/locales/en/system.ts b/apps/web/src/i18n/locales/en/system.ts index 7a1e36df5..8814f4f2a 100644 --- a/apps/web/src/i18n/locales/en/system.ts +++ b/apps/web/src/i18n/locales/en/system.ts @@ -1,5 +1,5 @@ export const system = { - help: "This installation's addresses and the settings every project shares. Sandboxes are changed on the Nodes page, startup settings in config.json.", + help: "This installation's addresses, each harness's default model and the settings every project shares. Sandboxes are changed on the Nodes page, startup settings in config.json.", refresh: "Refresh system configuration", loading: "Loading…", installation: { @@ -35,6 +35,62 @@ export const system = { fixed: "Fixed after install", noRestart: "None", }, + models: { + title: "Default model", + help: "The default model applies to Core-hosted Sessions and Sessions without an environment. Self-hosted Sessions must bring their own key, in the request or saved on the Agent. A provider in the request or on the Agent takes priority over this default.", + loadFailed: "Default models could not be loaded.", + refreshFailed: "Refresh failed; showing the last loaded default models.", + none: "Core reports no harnesses.", + enabled: "Enabled", + disabled: "Disabled", + default: "Default", + startupHelp: "Which harnesses are enabled, and the default one, are set in config.json and applied with parsar apply.", + harness: "Harness", + provider: "Model provider", + notSet: "Not set", + protocol: "Protocol", + baseUrl: "Base URL", + apiKey: "API key", + keyConfigured: "Configured", + keyNotConfigured: "Not configured", + contextWindow: "Context window", + maxOutputTokens: "Max output tokens", + updated: "Updated", + set: "Set", + replace: "Replace", + clear: "Clear", + setLabel: "Set the default model for {{harness}}", + replaceLabel: "Replace the default model for {{harness}}", + clearLabel: "Clear the default model for {{harness}}", + saved: "Default model for {{harness}} saved", + cleared: "Default model for {{harness}} cleared", + form: { + setTitle: "Set default model for {{harness}}", + replaceTitle: "Replace default model for {{harness}}", + baseUrlInvalid: "Enter a full URL, such as https://api.example.com/v1.", + apiKeyHelp: "Required every time. Core encrypts it and never shows it again.", + contextHelp: "The model's context window, in tokens. Optional.", + contextHelpRequired: "The model's context window, in tokens. MiniMax Code needs it.", + outputHelp: "The most tokens the model writes in one response. Optional; it needs a context window at least as large.", + outputHelpRequired: "The most tokens the model writes in one response. MiniMax Code needs it, with a context window at least as large.", + protocolHelp: "Each harness speaks one protocol; Core accepts no other.", + wholeNumber: "Enter a whole number.", + tooLarge: "Enter a whole number up to 2147483647.", + needsContext: "Set a context window at least this large.", + save: "Save", + saving: "Saving…", + uncertain: "Core did not confirm the change. The default models were read again; check them before trying again.", + noCredentialKey: "Core has no credential encryption key configured, so it can't store keys. Installer-based installs configure this automatically; for manual deployments, set AGENTS_API_CREDENTIAL_KEY_FILE for Core.", + }, + clearDialog: { + title: "Clear default model", + prompt: "Clear the default model for {{harness}}?", + consequence: "New Core-hosted Sessions and Sessions without an environment on it then need a model provider in the request or on their Agent. Existing Sessions keep theirs.", + confirm: "Clear default model", + busy: "Clearing…", + uncertain: "Core did not confirm clearing it. The default models were read again; check them before trying again.", + }, + }, sandbox: { title: "Sandboxes", change: "Change on the Nodes page", diff --git a/apps/web/src/i18n/locales/zh-CN/overview.ts b/apps/web/src/i18n/locales/zh-CN/overview.ts index 5484a65aa..f85a5a534 100644 --- a/apps/web/src/i18n/locales/zh-CN/overview.ts +++ b/apps/web/src/i18n/locales/zh-CN/overview.ts @@ -3,7 +3,7 @@ export const overview = { description: "这套 Parsar Core 部署在所有项目下的健康、容量、用量与故障。", gettingStarted: { title: "新手引导", - help: "让部署可用的三步,顺序不限。", + help: "让部署可用的四步,顺序不限。", progress: "已完成 {{done}} / {{total}}", tour: "认识控制台", dismiss: "隐藏新手引导", @@ -22,6 +22,11 @@ export const overview = { nodes: "打开节点", backend: "打开沙箱后端", }, + model: { + title: "设置默认模型", + body: "Core 托管的 Session 和没有环境的 Session 需要模型服务。应用也可以在每个请求里传入,或保存在 Agent 上;这里只设置默认值。", + open: "打开系统", + }, key: { title: "创建项目并签发 key", body: "应用用项目 API Key 调用 Core。", @@ -35,7 +40,7 @@ export const overview = { }, complete: { title: "一切就绪", - body: "沙箱已就绪,项目已有 key,第一个 Session 也已运行。", + body: "沙箱已就绪,已设置默认模型,项目已有 key,第一个 Session 也已运行。", dismiss: "关闭", }, }, diff --git a/apps/web/src/i18n/locales/zh-CN/system.ts b/apps/web/src/i18n/locales/zh-CN/system.ts index 513cde74c..b5ee0aa23 100644 --- a/apps/web/src/i18n/locales/zh-CN/system.ts +++ b/apps/web/src/i18n/locales/zh-CN/system.ts @@ -1,7 +1,7 @@ import type { system as english } from "../en/system"; type TranslationShape = { [K in keyof T]: T[K] extends string ? string : TranslationShape }; export const system: TranslationShape = { - help: "这个安装的地址,以及所有项目共用的设置。沙箱在“节点”页面修改,启动设置在 config.json 中修改。", + help: "这个安装的地址、每个执行框架的默认模型,以及所有项目共用的设置。沙箱在“节点”页面修改,启动设置在 config.json 中修改。", refresh: "刷新系统配置", loading: "正在加载…", installation: { @@ -37,6 +37,62 @@ export const system: TranslationShape = { fixed: "安装后不可修改", noRestart: "无", }, + models: { + title: "默认模型", + help: "默认模型作用于 Core 托管的 Session 和没有环境的 Session。自托管 Session 需要自带 key(请求里带或保存在 Agent 上)。请求或 Agent 上的模型服务优先于这里的默认值。", + loadFailed: "无法加载默认模型。", + refreshFailed: "刷新失败,显示的是上次加载的默认模型。", + none: "Core 没有报告任何执行框架。", + enabled: "已启用", + disabled: "未启用", + default: "默认", + startupHelp: "启用哪些执行框架、默认用哪个,都在 config.json 中设置,并用 parsar apply 生效。", + harness: "执行框架", + provider: "模型服务", + notSet: "未设置", + protocol: "协议", + baseUrl: "Base URL", + apiKey: "API key", + keyConfigured: "已配置", + keyNotConfigured: "未配置", + contextWindow: "上下文窗口", + maxOutputTokens: "最大输出 token", + updated: "更新时间", + set: "设置", + replace: "替换", + clear: "清除", + setLabel: "设置 {{harness}} 的默认模型", + replaceLabel: "替换 {{harness}} 的默认模型", + clearLabel: "清除 {{harness}} 的默认模型", + saved: "已保存 {{harness}} 的默认模型", + cleared: "已清除 {{harness}} 的默认模型", + form: { + setTitle: "设置 {{harness}} 的默认模型", + replaceTitle: "替换 {{harness}} 的默认模型", + baseUrlInvalid: "请输入完整的 URL,例如 https://api.example.com/v1。", + apiKeyHelp: "每次都必须填写。Core 会加密保存,之后不再显示。", + contextHelp: "模型的上下文窗口,单位为 token。可选。", + contextHelpRequired: "模型的上下文窗口,单位为 token。MiniMax Code 必须填写。", + outputHelp: "模型单次回复最多输出的 token 数。可选;需要不小于它的上下文窗口。", + outputHelpRequired: "模型单次回复最多输出的 token 数。MiniMax Code 必须填写,且上下文窗口不能小于它。", + protocolHelp: "每个执行框架只使用一种协议,Core 不接受其他协议。", + wholeNumber: "请输入整数。", + tooLarge: "请输入不超过 2147483647 的整数。", + needsContext: "请设置不小于该值的上下文窗口。", + save: "保存", + saving: "正在保存…", + uncertain: "Core 没有确认这次修改。已重新读取默认模型,请先核对再重试。", + noCredentialKey: "Core 没有配置凭据加密密钥,因此无法保存 key。用安装器安装的会自动配置;手动部署时,请为 Core 设置 AGENTS_API_CREDENTIAL_KEY_FILE。", + }, + clearDialog: { + title: "清除默认模型", + prompt: "清除 {{harness}} 的默认模型?", + consequence: "之后在这个执行框架上新建的 Core 托管 Session 和没有环境的 Session,需要在请求或 Agent 中提供模型服务。已有的 Session 不受影响。", + confirm: "清除默认模型", + busy: "正在清除…", + uncertain: "Core 没有确认清除。已重新读取默认模型,请先核对再重试。", + }, + }, sandbox: { title: "沙箱", change: "在“节点”页面修改", diff --git a/apps/web/src/lib/console-navigation.tsx b/apps/web/src/lib/console-navigation.tsx index 9c465cf69..58931f6fa 100644 --- a/apps/web/src/lib/console-navigation.tsx +++ b/apps/web/src/lib/console-navigation.tsx @@ -37,7 +37,7 @@ export function hashWithParams(base: string, params: RouteParams = {}): string { * Getting started. It lives only in memory: a reload, Back or any other * navigation drops it, so nothing reopens on its own. */ -export type ConsoleIntent = "add-node" | "create-project" | "issue-key" | "getting-started"; +export type ConsoleIntent = "add-node" | "create-project" | "issue-key" | "getting-started" | "default-model"; /** Whether a page can act on an intent now, not yet, or not at all (then it drops the intent). */ export type IntentReadiness = "ready" | "wait" | "unavailable"; diff --git a/apps/web/src/lib/harness-labels.ts b/apps/web/src/lib/harness-labels.ts new file mode 100644 index 000000000..3a77e5037 --- /dev/null +++ b/apps/web/src/lib/harness-labels.ts @@ -0,0 +1,7 @@ +import type { CoreHarnessKind, ModelProviderView } from "@agents-core-web/agents-client"; + +/** Harnesses by their product names, the same in every language. */ +export const harnessNames: Record = { claude_sdk: "Claude SDK", codex: "Codex", mcode: "MiniMax Code" }; + +/** Provider protocols by their product names, the same in every language. */ +export const protocolNames: Record = { anthropic: "Anthropic Messages", responses: "OpenAI Responses" }; diff --git a/docs/web/README.md b/docs/web/README.md index 85dcff6f8..3d92ff6be 100644 --- a/docs/web/README.md +++ b/docs/web/README.md @@ -21,7 +21,7 @@ management client, and it sends nothing to `/v1`. | Monitor | Sandbox metrics | Node capacity and hosted Runtime CPU and memory across Projects | | Monitor | Session log | Every Session, opening one Session's read-only conversation, trace and Turns; a self-hosted Session's page also manages its executor credentials | | Resources | Agents, Environment templates, Skills, Files, Vaults | Inspection and permitted deletion | -| Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System, read-only: the installation's public address, API base URL, ID and source commit, Core's config.json startup settings with where to change them, and the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and maintenance | +| Platform | Projects and keys, Nodes, System | Project and key lifecycle; sandbox deployment and nodes; System: the installation's public address, API base URL, ID and source commit (read-only), each harness's default model provider (write-only key) beside its read-only startup state, the sandbox configuration every Project shares: provider, sandbox size, Runtime or E2B template build, idle suspension and maintenance, and Core's config.json startup settings with where to change them | Missing data is shown as missing (—), never as zero. How each figure is read and bounded is recorded in [management interface coverage](protocol-coverage.md). diff --git a/docs/web/protocol-coverage.md b/docs/web/protocol-coverage.md index cd0b0a167..ec9c891eb 100644 --- a/docs/web/protocol-coverage.md +++ b/docs/web/protocol-coverage.md @@ -112,8 +112,19 @@ allows. Summary figures are cumulative per Session and are not billing records. Sessions without reported usage count toward coverage but not toward token sums, and the console shows missing values as missing, never as zero. The administrator audit -log (`GET /core/v1/audit-log`) is not consumed; System shows the installation and -the sandbox deployment. +log (`GET /core/v1/audit-log`) is not consumed; System shows the installation, +each harness's default model and the sandbox deployment. + +## Default models + +| Operation | Route | Console use | +| --- | --- | --- | +| List harnesses | `GET /core/v1/harnesses` | System's Default model cards (each harness's read-only `enabled` and `default`, and its `model_provider` view without the key); the Overview's Getting started (a default model on the default harness, or on any enabled harness when none is default) | +| Set or replace | `PUT /core/v1/harnesses/{harness}/model-provider` | **Set** or **Replace**: the complete provider with the write-only key, never prefilled and never retried; a 400 shows Core's message in the form, and a 503 `credential_storage_unavailable` says Core has no credential encryption key; then the list is read again | +| Clear | `DELETE /core/v1/harnesses/{harness}/model-provider` | **Clear**, confirmed, then the list is read again | + +The single-provider read (`GET /core/v1/harnesses/{harness}/model-provider`) is not +consumed; the list carries each provider. ## Sandbox administration diff --git a/docs/web/roadmap.md b/docs/web/roadmap.md index 47381bb1f..97d3e1584 100644 --- a/docs/web/roadmap.md +++ b/docs/web/roadmap.md @@ -17,8 +17,8 @@ contract. Public Agents API compatibility work is tracked in the - Sign-in with the deployment's Core key; the browser keeps only the session cookie. - Getting started: signing in opens the Overview, whose checklist leads to - sandboxes, a project and its key, and a first Session; an optional tour of the - console. + sandboxes, a default model, a project and its key, and a first Session; an + optional tour of the console. - Projects and keys: create, rename, archive, issue with one-time display, revoke; uncertain writes are reported, never replayed. - Resource inspection and permitted deletion; no execution, resource editors, @@ -43,7 +43,7 @@ writes, absent `/v1` proxying, secret handling and uncertain write outcomes. `apps/web/e2e` covers the browser side against `fixture-console.mjs`, a synthetic console service: Core key sign-in, a refused key and repeated attempts, and sign-out, -with no credential in browser storage; a fresh install from sign-in to Getting started, empty pages and its actions; Project creation, one-time key display, revocation and archive; an +with no credential in browser storage; a fresh install from sign-in to Getting started, empty pages and its actions; a harness's default model set, replaced and cleared with its key kept out of the browser, and a Core without a credential key; Project creation, one-time key display, revocation and archive; an unconfirmed key issue that is reported and never replayed; a refused deletion that keeps Core's reason; the monitor pages and a read-only Session conversation; node enrollment and removal.