feat: unify installation model catalog
This commit is contained in:
@@ -3,10 +3,8 @@ import { server } from "../test/msw";
|
||||
import {
|
||||
asPiManagementApiError,
|
||||
getPiManagementLogs,
|
||||
getPiManagementOptions,
|
||||
getPiManagementStatus,
|
||||
runPiManagementTest,
|
||||
savePiManagementConfig,
|
||||
} from "./pi-management";
|
||||
|
||||
test("Pi management client calls only the sanctioned sanitized endpoints", async () => {
|
||||
@@ -16,14 +14,6 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async
|
||||
calls.push({ method: request.method, path: new URL(request.url).pathname });
|
||||
return HttpResponse.json({ ready: true, version: "0.80.3", credentials: "present", config: {}, checkedAt: "2026-08-05T10:00:00.000Z" });
|
||||
}),
|
||||
http.get("/api/pi-management/options", ({ request }) => {
|
||||
calls.push({ method: request.method, path: new URL(request.url).pathname });
|
||||
return HttpResponse.json({ providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }], reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z" });
|
||||
}),
|
||||
http.put("/api/pi-management/config", async ({ request }) => {
|
||||
calls.push({ method: request.method, path: new URL(request.url).pathname, body: await request.json() });
|
||||
return HttpResponse.json({ provider: "zai", model: "glm-5.2", reasoning: "high", updatedAt: "2026-08-05T10:00:00.000Z" });
|
||||
}),
|
||||
http.post("/api/pi-management/test", ({ request }) => {
|
||||
calls.push({ method: request.method, path: new URL(request.url).pathname });
|
||||
return HttpResponse.json({ ready: true, checkedAt: "2026-08-05T10:00:00.000Z" });
|
||||
@@ -35,15 +25,11 @@ test("Pi management client calls only the sanctioned sanitized endpoints", async
|
||||
);
|
||||
|
||||
await expect(getPiManagementStatus()).resolves.toMatchObject({ version: "0.80.3", ready: true, credentials: "present" });
|
||||
await expect(getPiManagementOptions()).resolves.toMatchObject({ providers: ["zai"] });
|
||||
await expect(savePiManagementConfig({ provider: "zai", model: "glm-5.2", reasoning: "high" })).resolves.toMatchObject({ reasoning: "high" });
|
||||
await expect(runPiManagementTest()).resolves.toMatchObject({ ready: true });
|
||||
await expect(getPiManagementLogs()).resolves.toMatchObject({ lines: ["Pi smoke check succeeded"] });
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ method: "GET", path: "/api/pi-management/status" },
|
||||
{ method: "GET", path: "/api/pi-management/options" },
|
||||
{ method: "PUT", path: "/api/pi-management/config", body: { provider: "zai", model: "glm-5.2", reasoning: "high" } },
|
||||
{ method: "POST", path: "/api/pi-management/test" },
|
||||
{ method: "GET", path: "/api/pi-management/logs" },
|
||||
]);
|
||||
|
||||
@@ -17,13 +17,6 @@ export interface PiManagementStatus {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PiManagementOptions {
|
||||
providers: string[];
|
||||
models: Array<{ provider: string; id: string }>;
|
||||
reasoning: PiReasoning[];
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
export interface PiManagementTestResult {
|
||||
ready: boolean;
|
||||
checkedAt: string;
|
||||
@@ -37,9 +30,7 @@ export interface PiManagementLogs {
|
||||
|
||||
export type PiManagementApiErrorCode =
|
||||
| "pi_management_forbidden"
|
||||
| "pi_management_unavailable"
|
||||
| "pi_management_invalid_config"
|
||||
| "pi_management_write_failed";
|
||||
| "pi_management_unavailable";
|
||||
|
||||
export interface PiManagementApiError {
|
||||
status: number;
|
||||
@@ -50,8 +41,6 @@ export interface PiManagementApiError {
|
||||
const errorCodes = new Set<PiManagementApiErrorCode>([
|
||||
"pi_management_forbidden",
|
||||
"pi_management_unavailable",
|
||||
"pi_management_invalid_config",
|
||||
"pi_management_write_failed",
|
||||
]);
|
||||
|
||||
/** Narrows the sanctioned error code and derives its message locally. */
|
||||
@@ -65,11 +54,6 @@ export function asPiManagementApiError(error: unknown): PiManagementApiError | u
|
||||
}
|
||||
|
||||
export const getPiManagementStatus = () => apiFetch<PiManagementStatus>("/pi-management/status");
|
||||
export const getPiManagementOptions = () => apiFetch<PiManagementOptions>("/pi-management/options");
|
||||
export const savePiManagementConfig = (config: PiInstallationConfig) =>
|
||||
apiFetch<PiInstallationConfig & { updatedAt: string }>("/pi-management/config", {
|
||||
method: "PUT", body: JSON.stringify(config),
|
||||
});
|
||||
export const runPiManagementTest = () =>
|
||||
apiFetch<PiManagementTestResult>("/pi-management/test", { method: "POST" });
|
||||
export const getPiManagementLogs = () => apiFetch<PiManagementLogs>("/pi-management/logs");
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type AuthOperationPrecondition,
|
||||
} from "../auth/authOperation";
|
||||
import { getSettings } from "./settings";
|
||||
import { getWorkspace, listWorkspaces } from "./workspaces";
|
||||
import { listWorkspaces } from "./workspaces";
|
||||
import {
|
||||
WORKSPACE_POLICY_ERROR, WORKSPACE_SUMMARY_ERROR, WorkspaceSelectionError, workspacePolicyGate,
|
||||
workspacePreferences, type WorkspacePreference,
|
||||
@@ -34,24 +34,6 @@ async function selectedPreferences(precondition?: AuthOperationPrecondition): Pr
|
||||
});
|
||||
}
|
||||
|
||||
function reconcileWorkspacePolicy(preferences: WorkspacePreference, allowed: readonly string[], defaultModel?: string) {
|
||||
if (allowed.length === 0) throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||
const selected = preferences.provider && preferences.model
|
||||
? `${preferences.provider}/${preferences.model}`
|
||||
: undefined;
|
||||
if (selected && allowed.includes(selected)) return preferences;
|
||||
const replacement = defaultModel && allowed.includes(defaultModel) ? defaultModel : allowed[0];
|
||||
const separator = replacement.indexOf("/");
|
||||
if (separator <= 0 || separator === replacement.length - 1) {
|
||||
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||
}
|
||||
return workspacePreferences.save({
|
||||
...preferences,
|
||||
provider: replacement.slice(0, separator),
|
||||
model: replacement.slice(separator + 1),
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecondition): Promise<WorkspacePreference> {
|
||||
while (true) {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
@@ -104,45 +86,8 @@ async function ensureWorkspaceSelectionPolicy(precondition?: AuthOperationPrecon
|
||||
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||
}
|
||||
workspacePolicyGate.select(workspaceId);
|
||||
const outcome = await Promise.race([
|
||||
getWorkspace(workspaceId).then(
|
||||
(record) => ({ kind: "record" as const, record }),
|
||||
() => ({ kind: "error" as const }),
|
||||
),
|
||||
workspacePolicyGate.waitForCurrent(() => workspacePreferences.load()).then(
|
||||
(selection) => ({ kind: "selection" as const, selection }),
|
||||
),
|
||||
]);
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (outcome.kind === "selection") {
|
||||
if (outcome.selection.workspaceId !== workspaceId) continue;
|
||||
return outcome.selection;
|
||||
}
|
||||
if (outcome.kind === "error") {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||
workspacePolicyGate.reject(workspaceId);
|
||||
throw new WorkspaceSelectionError(WORKSPACE_POLICY_ERROR);
|
||||
}
|
||||
const { record } = outcome;
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId !== workspaceId) continue;
|
||||
let selection: WorkspacePreference;
|
||||
try {
|
||||
selection = reconcileWorkspacePolicy(
|
||||
preferences,
|
||||
record.workspace.llm_policy.allowed,
|
||||
record.workspace.llm_policy.default,
|
||||
);
|
||||
} catch (error) {
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
workspacePolicyGate.reject(workspaceId);
|
||||
throw error;
|
||||
}
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
workspacePolicyGate.resolve(workspaceId);
|
||||
requireAuthOperationPrecondition(precondition);
|
||||
if (workspacePreferences.load().workspaceId === workspaceId) return selection;
|
||||
return workspacePreferences.load();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export interface WorkspaceEvidence {
|
||||
|
||||
export interface CanonicalWorkspace {
|
||||
workspace: {
|
||||
schema_version: 3;
|
||||
schema_version: 4;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -76,20 +76,6 @@ export interface CanonicalWorkspace {
|
||||
timeout_ms?: number;
|
||||
supported_transports: ("postgres_direct" | "rest_api" | "ssh_tunnel")[];
|
||||
};
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant";
|
||||
collection: string;
|
||||
dimensions: 1024;
|
||||
distance: "cosine";
|
||||
}
|
||||
embedding: {
|
||||
provider: "ollama_internal";
|
||||
model: "qwen3-embedding:0.6b";
|
||||
dimensions: 1024;
|
||||
};
|
||||
};
|
||||
llm_policy: { default?: `${string}/${string}`; allowed: `${string}/${string}`[] };
|
||||
diagnostics?: CanonicalDiagnostics;
|
||||
evidence?: WorkspaceEvidence;
|
||||
}
|
||||
|
||||
@@ -13,111 +13,42 @@ const readyStatus = {
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
};
|
||||
|
||||
const options = {
|
||||
providers: ["zai", "deepseek"],
|
||||
models: [
|
||||
{ provider: "zai", id: "glm-5.2" },
|
||||
{ provider: "deepseek", id: "deepseek-v4" },
|
||||
],
|
||||
reasoning: ["low", "medium", "high"],
|
||||
checkedAt: "2026-08-05T10:00:00.000Z",
|
||||
};
|
||||
|
||||
function renderManagement() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}><PiManagement open onClose={() => undefined} /></QueryClientProvider>);
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<PiManagement open onClose={() => undefined} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get("/api/pi-management/status", () => HttpResponse.json(readyStatus)),
|
||||
http.get("/api/pi-management/options", () => HttpResponse.json(options)),
|
||||
);
|
||||
server.use(http.get("/api/pi-management/status", () => HttpResponse.json(readyStatus)));
|
||||
});
|
||||
|
||||
test("loads Pi version and readiness as an accessible operational rail", async () => {
|
||||
test("shows the catalog default as read-only installation state", async () => {
|
||||
renderManagement();
|
||||
|
||||
expect(screen.getByText("Loading Pi management…")).toBeVisible();
|
||||
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
|
||||
await screen.findByLabelText("Provider");
|
||||
const dialog = screen.getByRole("dialog", { name: "Pi management" });
|
||||
expect(dialog).toHaveAttribute("aria-modal", "false");
|
||||
expect(dialog).toHaveAttribute("data-work-area-panel");
|
||||
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="header"]')).toHaveLength(1);
|
||||
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="body"]')).toHaveLength(1);
|
||||
expect(await screen.findByText("Pi 0.80.3")).toBeVisible();
|
||||
expect(screen.getByTestId("pi-readiness-rail")).toHaveTextContent("Runtime ready");
|
||||
expect(screen.getByRole("status", { name: "Pi readiness" })).toHaveTextContent("Ready");
|
||||
expect(screen.getByText("Pi 0.80.3")).toBeVisible();
|
||||
expect(screen.getByText("Defaults ready")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("uses closed provider, model, and reasoning choices without a secret field or terminal", async () => {
|
||||
renderManagement();
|
||||
|
||||
const provider = await screen.findByLabelText("Provider");
|
||||
expect(provider).toHaveValue("zai");
|
||||
expect(screen.getByLabelText("Model")).toHaveValue("glm-5.2");
|
||||
expect(screen.getByLabelText("Reasoning level")).toHaveValue("medium");
|
||||
expect(within(provider).getAllByRole("option").map((option) => option.textContent)).toEqual(["zai", "deepseek"]);
|
||||
expect(screen.getByLabelText("Model")).toHaveTextContent("GLM 5.2");
|
||||
expect(screen.queryByRole("textbox", { name: /provider|model|reasoning|credential/i })).not.toBeInTheDocument();
|
||||
const catalog = screen.getByRole("region", { name: "Installation catalog default" });
|
||||
expect(catalog).toHaveTextContent("zai");
|
||||
expect(catalog).toHaveTextContent("glm-5.2");
|
||||
expect(catalog).toHaveTextContent("medium");
|
||||
expect(screen.getByRole("button", { name: "Test catalog default" })).toBeVisible();
|
||||
expect(screen.queryByRole("button", { name: /save defaults/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("combobox", { name: /provider|model|reasoning/i })).not.toBeInTheDocument();
|
||||
expect(document.querySelector('input[type="password"]')).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /terminal|shell access/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Update Pi" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("saves only a selected non-secret configuration", async () => {
|
||||
const user = userEvent.setup();
|
||||
let saved: unknown;
|
||||
server.use(http.put("/api/pi-management/config", async ({ request }) => {
|
||||
saved = await request.json();
|
||||
return HttpResponse.json({ ...saved as object, updatedAt: "2026-08-05T10:02:00.000Z" });
|
||||
}));
|
||||
renderManagement();
|
||||
|
||||
await user.selectOptions(await screen.findByLabelText("Provider"), "deepseek");
|
||||
await user.selectOptions(screen.getByLabelText("Reasoning level"), "high");
|
||||
await user.click(screen.getByRole("button", { name: "Save defaults" }));
|
||||
|
||||
await waitFor(() => expect(saved).toEqual({ provider: "deepseek", model: "deepseek-v4", reasoning: "high" }));
|
||||
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved");
|
||||
});
|
||||
|
||||
// Catches a provider switch updating only the saved config while leaving the credential rail
|
||||
// attached to the previously selected provider.
|
||||
test("shows authoritative credential presence after saving a different provider", async () => {
|
||||
const user = userEvent.setup();
|
||||
let saved = false;
|
||||
server.use(
|
||||
http.get("/api/pi-management/status", () => HttpResponse.json(saved ? {
|
||||
...readyStatus,
|
||||
credentials: "missing",
|
||||
config: { provider: "deepseek", model: "deepseek-v4", reasoning: "medium" },
|
||||
checkedAt: "2026-08-05T10:02:00.000Z",
|
||||
} : readyStatus)),
|
||||
http.put("/api/pi-management/config", async ({ request }) => {
|
||||
saved = true;
|
||||
return HttpResponse.json({
|
||||
...await request.json() as object,
|
||||
updatedAt: "2026-08-05T10:01:00.000Z",
|
||||
});
|
||||
}),
|
||||
);
|
||||
renderManagement();
|
||||
|
||||
expect(await screen.findByText("Credentials present")).toBeVisible();
|
||||
await user.selectOptions(screen.getByLabelText("Provider"), "deepseek");
|
||||
await user.click(screen.getByRole("button", { name: "Save defaults" }));
|
||||
|
||||
expect(await screen.findByText("Credentials missing")).toBeVisible();
|
||||
expect(screen.queryByText("Credentials present")).not.toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Provider")).toHaveValue("deepseek");
|
||||
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved");
|
||||
});
|
||||
|
||||
test("runs the saved-configuration test without changing credential presence", async () => {
|
||||
test("tests the catalog default without mutating credential presence", async () => {
|
||||
const user = userEvent.setup();
|
||||
let tests = 0;
|
||||
server.use(http.post("/api/pi-management/test", () => {
|
||||
@@ -128,204 +59,95 @@ test("runs the saved-configuration test without changing credential presence", a
|
||||
}));
|
||||
renderManagement();
|
||||
|
||||
const testButton = await screen.findByRole("button", { name: "Test saved defaults" });
|
||||
expect(screen.getByText("Defaults ready")).toBeVisible();
|
||||
expect(screen.queryByText("Changes are not saved yet.")).not.toBeInTheDocument();
|
||||
expect(testButton).toBeEnabled();
|
||||
const testButton = await screen.findByRole("button", { name: "Test catalog default" });
|
||||
await user.click(testButton);
|
||||
await waitFor(() => expect(tests).toBe(1));
|
||||
expect(await screen.findByText("Saved configuration test passed")).toBeVisible();
|
||||
expect(await screen.findByText("Catalog default test passed.")).toBeVisible();
|
||||
expect(screen.getByText("Credentials present")).toBeVisible();
|
||||
await user.click(screen.getByRole("button", { name: "Test saved defaults" }));
|
||||
expect(await screen.findByText("Saved configuration test failed")).toBeVisible();
|
||||
await user.click(testButton);
|
||||
expect(await screen.findByText(/Catalog default test failed/)).toBeVisible();
|
||||
expect(screen.getByText("Credentials present")).toBeVisible();
|
||||
});
|
||||
|
||||
test("fetches and displays bounded sanitized diagnostic logs only on request", async () => {
|
||||
test("fetches bounded sanitized diagnostics only on request", async () => {
|
||||
const user = userEvent.setup();
|
||||
let logRequests = 0;
|
||||
server.use(http.get("/api/pi-management/logs", () => {
|
||||
logRequests += 1;
|
||||
return HttpResponse.json({ lines: ["Pi smoke check succeeded", "provider token=[REDACTED]"], checkedAt: "2026-08-05T10:04:00.000Z" });
|
||||
return HttpResponse.json({
|
||||
lines: ["Pi smoke check succeeded", "provider token=[REDACTED]"],
|
||||
checkedAt: "2026-08-05T10:04:00.000Z",
|
||||
});
|
||||
}));
|
||||
renderManagement();
|
||||
|
||||
await screen.findByLabelText("Provider");
|
||||
await screen.findByText("Pi 0.80.3");
|
||||
expect(logRequests).toBe(0);
|
||||
await user.click(screen.getByRole("button", { name: "Show sanitized logs" }));
|
||||
expect(await screen.findByLabelText("Sanitized Pi diagnostics")).toHaveTextContent("provider token=[REDACTED]");
|
||||
expect(logRequests).toBe(1);
|
||||
expect(screen.queryByText("raw-provider-token")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("explains forbidden management access without offering mutation controls", async () => {
|
||||
server.use(
|
||||
http.get("/api/pi-management/status", () =>
|
||||
HttpResponse.json({ code: "pi_management_forbidden", error: "Pi management is not permitted" }, { status: 403 })),
|
||||
);
|
||||
test("explains forbidden management access without offering controls", async () => {
|
||||
server.use(http.get("/api/pi-management/status", () =>
|
||||
HttpResponse.json(
|
||||
{ code: "pi_management_forbidden", error: "Pi management is not permitted" },
|
||||
{ status: 403 },
|
||||
)));
|
||||
renderManagement();
|
||||
|
||||
expect(await screen.findByRole("alert", { name: "Pi management unavailable" })).toHaveTextContent("Pi management is not permitted");
|
||||
expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Save defaults" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Test saved defaults" })).not.toBeInTheDocument();
|
||||
expect(await screen.findByRole("alert", { name: "Pi management unavailable" }))
|
||||
.toHaveTextContent("Pi management is not permitted");
|
||||
expect(screen.queryByRole("button", { name: "Test catalog default" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows a seven-step host-terminal workflow in scrollable platform tabs", async () => {
|
||||
test("shows the single-source host workflow in accessible platform tabs", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderManagement();
|
||||
|
||||
const tablist = await screen.findByRole("tablist", { name: "Pi host platform" });
|
||||
const [linuxTab, macosTab, windowsTab] = within(tablist).getAllByRole("tab");
|
||||
expect([linuxTab, macosTab, windowsTab].map((tab) => tab.textContent)).toEqual(["Linux", "macOS", "Windows"]);
|
||||
expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveAttribute("data-width", "standard");
|
||||
expect(screen.getByTestId("pi-platform-instructions-scroll")).toHaveClass("overflow-y-scroll");
|
||||
expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument();
|
||||
expect([linuxTab, macosTab, windowsTab].every((tab) => tab.getAttribute("aria-selected") === "false")).toBe(true);
|
||||
expect(linuxTab).toHaveAttribute("tabindex", "0");
|
||||
expect(macosTab).toHaveAttribute("tabindex", "-1");
|
||||
expect(windowsTab).toHaveAttribute("tabindex", "-1");
|
||||
for (const tab of [linuxTab, macosTab, windowsTab]) {
|
||||
const panelId = tab.getAttribute("aria-controls");
|
||||
const panel = panelId ? document.getElementById(panelId) : null;
|
||||
expect(panel).toBeInTheDocument();
|
||||
expect(panel).toHaveAttribute("role", "tabpanel");
|
||||
expect(panel).toHaveAttribute("aria-labelledby", tab.id);
|
||||
expect(panel).toHaveAttribute("hidden");
|
||||
expect(panel).toHaveAttribute("tabindex", "-1");
|
||||
}
|
||||
expect(screen.getByText("Using the host terminal:")).toBeVisible();
|
||||
expect(screen.queryByText(/not this browser page/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("region", { name: "Host update" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Copy update command" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/:5173/)).not.toBeInTheDocument();
|
||||
expect(await screen.findByText("Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.")).toBeVisible();
|
||||
expect(screen.getByText("Shows at most 200 recent lines with declared secret values removed.")).toBeVisible();
|
||||
|
||||
linuxTab.focus();
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(macosTab).toHaveFocus();
|
||||
expect(macosTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(macosTab).toHaveAttribute("tabindex", "0");
|
||||
expect(linuxTab).toHaveAttribute("tabindex", "-1");
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(windowsTab).toHaveFocus();
|
||||
expect(windowsTab).toHaveAttribute("aria-selected", "true");
|
||||
expect(windowsTab).toHaveAttribute("tabindex", "0");
|
||||
expect(macosTab).toHaveAttribute("tabindex", "-1");
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(linuxTab).toHaveFocus();
|
||||
expect(linuxTab).toHaveAttribute("aria-selected", "true");
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
expect(windowsTab).toHaveFocus();
|
||||
await user.keyboard("{Home}");
|
||||
expect(linuxTab).toHaveFocus();
|
||||
await user.keyboard("{End}");
|
||||
expect(windowsTab).toHaveFocus();
|
||||
|
||||
await user.click(windowsTab);
|
||||
expect(windowsTab).toHaveAttribute("aria-selected", "false");
|
||||
expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument();
|
||||
await user.keyboard(" ");
|
||||
expect(windowsTab).toHaveAttribute("aria-selected", "true");
|
||||
await user.keyboard(" ");
|
||||
expect(windowsTab).toHaveAttribute("aria-selected", "false");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(windowsTab).toHaveAttribute("aria-selected", "true");
|
||||
await user.keyboard("{Enter}");
|
||||
expect(windowsTab).toHaveAttribute("aria-selected", "false");
|
||||
|
||||
await user.click(linuxTab);
|
||||
|
||||
const linux = screen.getByRole("tabpanel", { name: "Linux" });
|
||||
expect(within(linux).getAllByRole("listitem")).toHaveLength(7);
|
||||
expect(within(linux).getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual([
|
||||
"Open the project root",
|
||||
"Edit the provider catalog",
|
||||
"Enable the model",
|
||||
"Edit the Installation Model Catalog",
|
||||
"Choose eligibility and defaults",
|
||||
"Complete the provider setup",
|
||||
"Reload Pi configuration",
|
||||
"Update the Pi version",
|
||||
"Diagnose and recover a failed operation",
|
||||
]);
|
||||
expect(linux).toHaveTextContent("cd /absolute/path/to/ThothII");
|
||||
expect(linux).toHaveTextContent("tht pi status");
|
||||
expect(linux).toHaveTextContent("tht pi doctor");
|
||||
expect(linux).toHaveTextContent("Invalid JSON");
|
||||
expect(linux).toHaveTextContent("Provider connectivity");
|
||||
expect(linux).toHaveTextContent("If maintenance is inactive");
|
||||
expect(linux).toHaveTextContent("active after an update");
|
||||
expect(linux).toHaveTextContent("active after a restart");
|
||||
expect(linux).toHaveTextContent("run every command in this list from that directory");
|
||||
expect(linux).toHaveTextContent("deploy/pi/models.json");
|
||||
expect(linux).toHaveTextContent("deploy/pi/settings.json");
|
||||
expect(linux).toHaveTextContent("baseUrl is the provider API endpoint");
|
||||
expect(linux).toHaveTextContent("enabledModels uses provider/model identifiers");
|
||||
expect(linux).toHaveTextContent("During the initial installation");
|
||||
expect(linux).toHaveTextContent("Pi credentials file location");
|
||||
expect(linux).toHaveTextContent("tht pi restart --yes --drain");
|
||||
expect(linux).toHaveTextContent("tht pi update");
|
||||
expect(linux).toHaveTextContent("tht pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain");
|
||||
expect(linux).toHaveTextContent("tht pi maintenance status");
|
||||
expect(linux).toHaveTextContent("tht pi logs");
|
||||
expect(linux).toHaveTextContent("tht pi rollback --yes");
|
||||
expect(linux).toHaveTextContent("tht pi maintenance recover --yes");
|
||||
expect(linux).not.toHaveTextContent("PI_AUTH_FILE");
|
||||
expect(linux).not.toHaveTextContent("thothctl");
|
||||
expect(linux).not.toHaveTextContent("~/.pi/agent/");
|
||||
for (const expected of [
|
||||
"deploy/<installation-id>/thothii-installation.yaml",
|
||||
"modelCatalog.defaults.session",
|
||||
"provider/model",
|
||||
"generated projections",
|
||||
"tht pi restart --yes --drain",
|
||||
"tht pi doctor",
|
||||
"Invalid YAML",
|
||||
]) expect(linux).toHaveTextContent(expected);
|
||||
for (const retired of ["deploy/pi/models.json", "deploy/pi/settings.json", "tht pi configure", "enabledModels"])
|
||||
expect(linux).not.toHaveTextContent(retired);
|
||||
|
||||
await user.click(macosTab);
|
||||
const macos = screen.getByRole("tabpanel", { name: "macOS" });
|
||||
expect(within(macos).getAllByRole("listitem")).toHaveLength(7);
|
||||
expect(within(macos).getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual([
|
||||
"Open the project root",
|
||||
"Edit the provider catalog",
|
||||
"Enable the model",
|
||||
"Complete the provider setup",
|
||||
"Reload Pi configuration",
|
||||
"Update the Pi version",
|
||||
"Diagnose and recover a failed operation",
|
||||
]);
|
||||
expect(macos).toHaveTextContent("cd /absolute/path/to/ThothII");
|
||||
expect(macos).toHaveTextContent("run every command in this list from that directory");
|
||||
expect(macos).toHaveTextContent("deploy/pi/models.json");
|
||||
expect(macos).toHaveTextContent("deploy/pi/settings.json");
|
||||
expect(macos).toHaveTextContent("tht pi restart --yes --drain");
|
||||
expect(macos).toHaveTextContent("tht pi update");
|
||||
expect(macos).toHaveTextContent("tht pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain");
|
||||
expect(macos).toHaveTextContent("tht pi maintenance status");
|
||||
expect(macos).toHaveTextContent("tht pi logs");
|
||||
expect(macos).toHaveTextContent("tht pi rollback --yes");
|
||||
expect(macos).toHaveTextContent("tht pi maintenance recover --yes");
|
||||
|
||||
expect(screen.getByRole("tabpanel", { name: "macOS" }))
|
||||
.toHaveTextContent("deploy/<installation-id>/thothii-installation.yaml");
|
||||
await user.click(windowsTab);
|
||||
const windows = screen.getByRole("tabpanel", { name: "Windows" });
|
||||
expect(within(windows).getAllByRole("listitem")).toHaveLength(7);
|
||||
expect(within(windows).getAllByRole("heading", { level: 4 }).map((heading) => heading.textContent)).toEqual([
|
||||
"Open the project root",
|
||||
"Edit the provider catalog",
|
||||
"Enable the model",
|
||||
"Complete the provider setup",
|
||||
"Reload Pi configuration",
|
||||
"Update the Pi version",
|
||||
"Diagnose and recover a failed operation",
|
||||
]);
|
||||
expect(windows).toHaveTextContent("Set-Location C:\\absolute\\path\\to\\ThothII");
|
||||
expect(windows).toHaveTextContent("run every command in this list from that directory");
|
||||
expect(windows).toHaveTextContent("deploy\\pi\\models.json");
|
||||
expect(windows).toHaveTextContent("deploy\\pi\\settings.json");
|
||||
expect(windows).toHaveTextContent('tht pi restart --yes --drain');
|
||||
expect(windows).toHaveTextContent('tht pi update');
|
||||
expect(windows).toHaveTextContent('tht pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain');
|
||||
expect(windows).toHaveTextContent('tht pi maintenance status');
|
||||
expect(windows).toHaveTextContent('tht pi logs');
|
||||
expect(windows).toHaveTextContent('tht pi rollback --yes');
|
||||
expect(windows).toHaveTextContent('tht pi maintenance recover --yes');
|
||||
expect(windows).not.toHaveTextContent("PI_AUTH_FILE");
|
||||
expect(windows).not.toHaveTextContent("thothctl");
|
||||
expect(screen.getByRole("tabpanel", { name: "Windows" }))
|
||||
.toHaveTextContent("deploy\\<installation-id>\\thothii-installation.yaml");
|
||||
});
|
||||
|
||||
|
||||
test("reloads installation defaults when the panel is reopened", async () => {
|
||||
test("reloads the catalog-derived default when reopened", async () => {
|
||||
let statusCalls = 0;
|
||||
server.use(http.get("/api/pi-management/status", () => {
|
||||
statusCalls += 1;
|
||||
@@ -334,70 +156,32 @@ test("reloads installation defaults when the panel is reopened", async () => {
|
||||
: { ...readyStatus, config: { provider: "deepseek", model: "deepseek-v4", reasoning: "high" } });
|
||||
}));
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const view = render(<QueryClientProvider client={client}><PiManagement open onClose={() => undefined} /></QueryClientProvider>);
|
||||
const view = render(
|
||||
<QueryClientProvider client={client}><PiManagement open onClose={() => undefined} /></QueryClientProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText("Provider")).toHaveValue("zai");
|
||||
view.rerender(<QueryClientProvider client={client}><PiManagement open={false} onClose={() => undefined} /></QueryClientProvider>);
|
||||
expect(await screen.findByText("glm-5.2")).toBeVisible();
|
||||
view.rerender(
|
||||
<QueryClientProvider client={client}><PiManagement open={false} onClose={() => undefined} /></QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Pi management" })).not.toBeInTheDocument());
|
||||
view.rerender(<QueryClientProvider client={client}><PiManagement open onClose={() => undefined} /></QueryClientProvider>);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText("Provider")).toHaveValue("deepseek"));
|
||||
view.rerender(
|
||||
<QueryClientProvider client={client}><PiManagement open onClose={() => undefined} /></QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText("deepseek-v4")).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows an explicit recoverable incomplete state when no provider model is available", async () => {
|
||||
server.use(
|
||||
http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })),
|
||||
http.get("/api/pi-management/options", () => HttpResponse.json({
|
||||
...options,
|
||||
providers: ["zai"],
|
||||
models: [],
|
||||
})),
|
||||
);
|
||||
test("shows an explicit incomplete state when the catalog default is unavailable", async () => {
|
||||
server.use(http.get("/api/pi-management/status", () => HttpResponse.json({
|
||||
...readyStatus,
|
||||
config: { reasoning: "medium" },
|
||||
})));
|
||||
renderManagement();
|
||||
|
||||
const incomplete = await screen.findByRole("alert", { name: "Pi configuration incomplete" });
|
||||
expect(incomplete).toHaveTextContent("No provider or model options are available");
|
||||
expect(incomplete).toHaveTextContent("Check the host-managed Pi model configuration");
|
||||
expect(screen.queryByText("Loading Pi management…")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Save defaults" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("keeps suggested draft choices distinct from invalid persisted defaults until save succeeds", async () => {
|
||||
let configured = false;
|
||||
const persisted = {
|
||||
...readyStatus,
|
||||
credentials: "missing",
|
||||
config: { provider: "retired", model: "old-model", reasoning: "medium" },
|
||||
};
|
||||
server.use(
|
||||
http.get("/api/pi-management/status", () => HttpResponse.json(configured ? {
|
||||
...readyStatus,
|
||||
credentials: "missing",
|
||||
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
|
||||
checkedAt: "2026-08-05T10:05:00.000Z",
|
||||
} : persisted)),
|
||||
http.put("/api/pi-management/config", () => {
|
||||
configured = true;
|
||||
return HttpResponse.json({
|
||||
provider: "zai",
|
||||
model: "glm-5.2",
|
||||
reasoning: "medium",
|
||||
updatedAt: "2026-08-05T10:05:00.000Z",
|
||||
});
|
||||
}),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderManagement();
|
||||
|
||||
expect(await screen.findByLabelText("Provider")).toHaveValue("zai");
|
||||
expect(screen.getByText("Defaults incomplete")).toBeVisible();
|
||||
expect(screen.getByText("Suggested choices are not saved yet.")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save defaults" }));
|
||||
expect(await screen.findByText("Defaults ready")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeEnabled();
|
||||
expect(incomplete).toHaveTextContent("The catalog default is unavailable");
|
||||
expect(incomplete).toHaveTextContent("modelCatalog.defaults.session");
|
||||
expect(screen.getByRole("button", { name: "Test catalog default" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test.each(["present", "missing"] as const)(
|
||||
@@ -408,47 +192,6 @@ test.each(["present", "missing"] as const)(
|
||||
credentials,
|
||||
})));
|
||||
renderManagement();
|
||||
|
||||
expect(await screen.findByText(`Credentials ${credentials}`)).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
test("labels smoke only as a saved-configuration test and resets it when the draft changes", async () => {
|
||||
server.use(
|
||||
http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })),
|
||||
http.post("/api/pi-management/test", () => HttpResponse.json({
|
||||
ready: true,
|
||||
checkedAt: "2026-08-05T10:06:00.000Z",
|
||||
})),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderManagement();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Test saved defaults" }));
|
||||
expect(await screen.findByText("Saved configuration test passed")).toBeVisible();
|
||||
expect(screen.getByText("Credentials present")).toBeVisible();
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("Provider"), "deepseek");
|
||||
expect(screen.getByText("Saved configuration test not run")).toBeVisible();
|
||||
expect(screen.getByText("Save these changes before testing. The test always uses saved defaults.")).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "Test saved defaults" })).toBeDisabled();
|
||||
expect(screen.getByText("Credentials present")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a failed saved-configuration test does not change backend credential presence", async () => {
|
||||
server.use(
|
||||
http.get("/api/pi-management/status", () => HttpResponse.json({ ...readyStatus, credentials: "present" })),
|
||||
http.post("/api/pi-management/test", () => HttpResponse.json({
|
||||
ready: false,
|
||||
message: "Pi runtime is unavailable",
|
||||
checkedAt: "2026-08-05T10:07:00.000Z",
|
||||
})),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderManagement();
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Test saved defaults" }));
|
||||
expect(await screen.findByText("Saved configuration test failed")).toBeVisible();
|
||||
expect(screen.getByText("Credentials present")).toBeVisible();
|
||||
expect(screen.queryByText("Credentials missing")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,59 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText } from "lucide-react";
|
||||
import { CheckCircle2, CircleAlert, FlaskConical, LoaderCircle, ScrollText } from "lucide-react";
|
||||
import {
|
||||
asPiManagementApiError,
|
||||
getPiManagementLogs,
|
||||
getPiManagementOptions,
|
||||
getPiManagementStatus,
|
||||
runPiManagementTest,
|
||||
savePiManagementConfig,
|
||||
type PiInstallationConfig,
|
||||
type PiManagementOptions,
|
||||
} from "../api/pi-management";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { WorkAreaPanel } from "./WorkAreaPanel";
|
||||
|
||||
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60";
|
||||
|
||||
type Feedback = { tone: "success" | "error"; message: string } | undefined;
|
||||
type SmokeState = "passed" | "failed" | undefined;
|
||||
|
||||
function suggestedConfig(status: { config: Partial<PiInstallationConfig> }, options: PiManagementOptions): PiInstallationConfig | undefined {
|
||||
const provider = status.config.provider && options.providers.includes(status.config.provider)
|
||||
? status.config.provider
|
||||
: options.providers[0];
|
||||
const model = status.config.model && options.models.some((item) => item.provider === provider && item.id === status.config.model)
|
||||
? status.config.model
|
||||
: options.models.find((item) => item.provider === provider)?.id;
|
||||
const reasoning = status.config.reasoning && options.reasoning.includes(status.config.reasoning)
|
||||
? status.config.reasoning
|
||||
: options.reasoning[0];
|
||||
return provider && model && reasoning ? { provider, model, reasoning } : undefined;
|
||||
}
|
||||
|
||||
function isSupportedConfig(
|
||||
config: Partial<PiInstallationConfig> | undefined,
|
||||
options: PiManagementOptions | undefined,
|
||||
): config is PiInstallationConfig {
|
||||
return Boolean(
|
||||
config?.provider
|
||||
&& config.model
|
||||
&& config.reasoning
|
||||
&& options?.providers.includes(config.provider)
|
||||
&& options.models.some((model) => model.provider === config.provider && model.id === config.model)
|
||||
&& options.reasoning.includes(config.reasoning),
|
||||
);
|
||||
}
|
||||
|
||||
function sameConfig(a: Partial<PiInstallationConfig> | undefined, b: PiInstallationConfig | undefined): boolean {
|
||||
return Boolean(
|
||||
a?.provider === b?.provider
|
||||
&& a?.model === b?.model
|
||||
&& a?.reasoning === b?.reasoning,
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
return asPiManagementApiError(error)?.message ?? fallback;
|
||||
}
|
||||
@@ -97,15 +56,10 @@ function RailItem({ label, value, state }: { label: string; value: string; state
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return <label className="grid gap-1.5 text-xs font-semibold text-foreground"><span>{label}</span>{children}</label>;
|
||||
}
|
||||
|
||||
type PiPlatform = "linux" | "macos" | "windows";
|
||||
|
||||
type PiPlatformDetails = {
|
||||
modelsPath: string;
|
||||
settingsPath: string;
|
||||
catalogPath: string;
|
||||
terminal: string;
|
||||
changeDirectoryCommand: string;
|
||||
credentialProtection: string;
|
||||
@@ -123,8 +77,7 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet
|
||||
id: "linux",
|
||||
label: "Linux",
|
||||
details: {
|
||||
modelsPath: "deploy/pi/models.json",
|
||||
settingsPath: "deploy/pi/settings.json",
|
||||
catalogPath: "deploy/<installation-id>/thothii-installation.yaml",
|
||||
terminal: "a terminal",
|
||||
changeDirectoryCommand: "cd /absolute/path/to/ThothII",
|
||||
credentialProtection: "a protected host file with mode 0600",
|
||||
@@ -141,8 +94,7 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet
|
||||
id: "macos",
|
||||
label: "macOS",
|
||||
details: {
|
||||
modelsPath: "deploy/pi/models.json",
|
||||
settingsPath: "deploy/pi/settings.json",
|
||||
catalogPath: "deploy/<installation-id>/thothii-installation.yaml",
|
||||
terminal: "Terminal",
|
||||
changeDirectoryCommand: "cd /absolute/path/to/ThothII",
|
||||
credentialProtection: "a protected host file with mode 0600",
|
||||
@@ -159,8 +111,7 @@ const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDet
|
||||
id: "windows",
|
||||
label: "Windows",
|
||||
details: {
|
||||
modelsPath: "deploy\\pi\\models.json",
|
||||
settingsPath: "deploy\\pi\\settings.json",
|
||||
catalogPath: "deploy\\<installation-id>\\thothii-installation.yaml",
|
||||
terminal: "PowerShell",
|
||||
changeDirectoryCommand: "Set-Location C:\\absolute\\path\\to\\ThothII",
|
||||
credentialProtection: "a protected host file with a user-only ACL",
|
||||
@@ -189,37 +140,37 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
|
||||
<PiCodeBlock className="mt-1">project-root/
|
||||
├── compose.yaml
|
||||
├── deploy/
|
||||
│ └── pi/
|
||||
│ ├── models.json
|
||||
│ └── settings.json
|
||||
│ └── <installation-id>/
|
||||
│ └── thothii-installation.yaml
|
||||
└── docker/</PiCodeBlock>
|
||||
<p className="mt-2 text-muted-foreground">The <code>deploy/</code> directory contains the selected installation descriptor and profile files. You do not need to create or manage a <code>bin/</code> directory: <code>tht</code> is the installed host CLI. If discovery finds multiple descriptors, pass the intended one explicitly with <code>--installation <absolute-path>/thothii-installation.yaml</code>. If <code>tht</code> is not on your <code>PATH</code>, install it using the installation guide.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h4 className="font-semibold text-foreground">Edit the provider catalog</h4>
|
||||
<p className="mt-1 text-muted-foreground">Edit <code>{details.modelsPath}</code> only when adding or correcting a provider/model definition. The provider catalog is an address book/map of the services Pi can call: each entry supplies the API endpoint and format, and lists the model identifiers offered there. It does not enable a model and never contains credentials. Provider integrations must remain declarative; do not add model-provider code under <code>harness/.pi/extensions/</code>.</p>
|
||||
<h4 className="font-semibold text-foreground">Edit the Installation Model Catalog</h4>
|
||||
<p className="mt-1 text-muted-foreground">Edit <code>{details.catalogPath}</code> when adding or correcting a provider or model. Its <code>modelCatalog</code> block is the only authored model source for sessions, metadata generation, and embedding. It declares endpoint references and authentication modes, but never secret values. Provider integrations remain declarative; do not add model-provider code under <code>harness/.pi/extensions/</code> or edit files under <code>generated/</code>.</p>
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-muted-foreground">
|
||||
<dt className="font-mono text-foreground">baseUrl </dt><dd>is the provider API endpoint.</dd>
|
||||
<dt className="font-mono text-foreground">api </dt><dd>selects the provider API format.</dd>
|
||||
<dt className="font-mono text-foreground">models </dt><dd>lists that provider's available models.</dd>
|
||||
<dt className="font-mono text-foreground">id </dt><dd>is the model identifier.</dd>
|
||||
<dt className="font-mono text-foreground">name </dt><dd>is the model name shown to operators.</dd>
|
||||
<dt className="font-mono text-foreground">providers </dt><dd>maps stable provider keys to endpoint, authentication, and runtime adapters.</dd>
|
||||
<dt className="font-mono text-foreground">models </dt><dd>maps upstream model keys; the canonical identity is provider/model.</dd>
|
||||
<dt className="font-mono text-foreground">session </dt><dd>makes a model selectable for interactive work.</dd>
|
||||
<dt className="font-mono text-foreground">metadataGeneration </dt><dd>makes a model available to metadata generation.</dd>
|
||||
<dt className="font-mono text-foreground">embedding </dt><dd>declares the one installation embedding identity and its dimensions.</dd>
|
||||
</dl>
|
||||
</li>
|
||||
<li>
|
||||
<h4 className="font-semibold text-foreground">Enable the model</h4>
|
||||
<p className="mt-1 text-muted-foreground">Enable a model after the provider setup is ready. For a custom provider, first define the provider and model ID in <code>{details.modelsPath}</code>; built-in Pi models may already be known without a local catalog entry. Then edit <code>{details.settingsPath}</code> in the project root, add the exact <code>provider/model</code> identifier to <code>enabledModels</code>, and reload Pi. Do not edit it merely to choose the default provider/model: use this page or <code>tht pi configure</code>.</p>
|
||||
<h4 className="font-semibold text-foreground">Choose eligibility and defaults</h4>
|
||||
<p className="mt-1 text-muted-foreground">Use <code>modelCatalog.defaults.session</code> as the single default for new sessions. Add a <code>session</code> or <code>metadataGeneration</code> block to make a model eligible for that use. When metadata generation is present, set <code>defaults.metadataGeneration</code>. Runtime files such as Pi <code>models.json</code> and <code>settings.json</code> are generated projections and must not be edited.</p>
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-muted-foreground">
|
||||
<dt className="font-mono text-foreground">enabledModels </dt><dd>uses provider/model identifiers to choose the models available for new Pi work.</dd>
|
||||
<dt className="font-mono text-foreground">defaults.session </dt><dd>contains exactly one canonical provider/model identity.</dd>
|
||||
<dt className="font-mono text-foreground">defaults.metadataGeneration </dt><dd>selects the metadata model when that use exists.</dd>
|
||||
</dl>
|
||||
</li>
|
||||
<li>
|
||||
<h4 className="font-semibold text-foreground">Complete the provider setup</h4>
|
||||
<p className="mt-1 text-muted-foreground">During the initial installation, run <code>tht setup</code> and complete the prompt named “Pi credentials file location”; it can create the empty protected template. On an existing installation with a missing credential, correct the protected credential file selected during setup by following the installation guide. Keep {details.credentialProtection}, then restart and run <code>tht pi doctor</code> and <code>tht pi test</code>. Never paste credentials into either JSON file, the page, a command, or a log.</p>
|
||||
<p className="mt-1 text-muted-foreground">During the initial installation, run <code>tht setup</code> and complete the prompt named “Pi credentials file location”; it can create the empty protected template. On an existing installation with a missing credential, correct the protected credential file selected during setup by following the installation guide. Keep {details.credentialProtection}, then restart and run <code>tht pi doctor</code> and <code>tht pi test</code>. Never paste credentials into the installation YAML, this page, a command, or a log: YAML may contain only approved environment-variable names or authentication modes.</p>
|
||||
</li>
|
||||
<li>
|
||||
<h4 className="font-semibold text-foreground">Reload Pi configuration</h4>
|
||||
<p className="mt-1 text-muted-foreground">After changing <code>models.json</code>, <code>settings.json</code>, or the provider credential, reload the running <code>core</code> service so it reads the new files. This is a configuration reload, not a Pi version update; it keeps the current image.</p>
|
||||
<p className="mt-1 text-muted-foreground">After changing the Installation Model Catalog or a provider credential, reload the running <code>core</code> service. The command validates the YAML and regenerates every runtime projection before recreation. This is a configuration reload, not a Pi version update; it keeps the current image.</p>
|
||||
<PiCodeBlock className="mt-2">{details.restartCommand}</PiCodeBlock>
|
||||
</li>
|
||||
<li>
|
||||
@@ -234,9 +185,9 @@ function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
|
||||
<p className="mt-1 text-muted-foreground">Start with these diagnostics, in order:</p>
|
||||
<PiCodeBlock className="mt-2 text-[11px] text-muted-foreground">{details.diagnosticCommands}</PiCodeBlock>
|
||||
<dl className="mt-2 grid gap-2 text-muted-foreground">
|
||||
<dt className="font-semibold text-foreground">Configuration</dt><dd>Invalid JSON: fix the reported file and validate it. A provider/model ID mismatch or a model missing from <code>enabledModels</code>: correct the IDs or policy, then reload.</dd>
|
||||
<dt className="font-semibold text-foreground">Configuration</dt><dd>Invalid YAML, an unknown model default, or an incompatible use/adapter combination: fix the reported <code>modelCatalog</code> field, then reload. Do not repair generated JSON directly.</dd>
|
||||
<dt className="font-semibold text-foreground">Credentials</dt><dd>Missing or unreadable credential: correct the protected credential file selected during setup and its permissions, without printing the file.</dd>
|
||||
<dt className="font-semibold text-foreground">Provider connectivity</dt><dd>Wrong <code>baseUrl</code>, network, or provider error: correct the endpoint or network, then retry.</dd>
|
||||
<dt className="font-semibold text-foreground">Provider connectivity</dt><dd>Wrong <code>baseUrl</code>, network, or provider error: correct the catalog endpoint or network, then retry.</dd>
|
||||
<dt className="font-semibold text-foreground">Docker and disk</dt><dd>Unhealthy Docker or insufficient disk: restore Docker health or free space before retrying.</dd>
|
||||
</dl>
|
||||
<p className="mt-2 text-muted-foreground">If maintenance is inactive, no recovery is needed: correct the cause and retry the original command.</p>
|
||||
@@ -325,30 +276,19 @@ function PiPlatformInstructions() {
|
||||
|
||||
export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [draft, setDraft] = useState<PiInstallationConfig>();
|
||||
const [feedback, setFeedback] = useState<Feedback>();
|
||||
const [smokeState, setSmokeState] = useState<SmokeState>();
|
||||
const [logsRequested, setLogsRequested] = useState(false);
|
||||
const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open });
|
||||
const optionsQuery = useQuery({ queryKey: ["pi-management", "options"], queryFn: getPiManagementOptions, enabled: open });
|
||||
const logsQuery = useQuery({ queryKey: ["pi-management", "logs"], queryFn: getPiManagementLogs, enabled: open && logsRequested });
|
||||
const models = useMemo(
|
||||
() => optionsQuery.data?.models.filter((model) => model.provider === draft?.provider) ?? [],
|
||||
[draft?.provider, optionsQuery.data?.models],
|
||||
const catalogReady = Boolean(
|
||||
statusQuery.data?.config.provider
|
||||
&& statusQuery.data.config.model
|
||||
&& statusQuery.data.config.reasoning,
|
||||
);
|
||||
const initialDraft = useMemo(
|
||||
() => statusQuery.data && optionsQuery.data
|
||||
? suggestedConfig(statusQuery.data, optionsQuery.data)
|
||||
: undefined,
|
||||
[optionsQuery.data, statusQuery.data],
|
||||
);
|
||||
const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data);
|
||||
const validDraft = isSupportedConfig(draft, optionsQuery.data);
|
||||
const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDraft(undefined);
|
||||
setFeedback(undefined);
|
||||
setSmokeState(undefined);
|
||||
setLogsRequested(false);
|
||||
@@ -356,22 +296,6 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
}
|
||||
}, [open, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (draft || !initialDraft) return;
|
||||
setDraft(initialDraft);
|
||||
}, [draft, initialDraft]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: savePiManagementConfig,
|
||||
onSuccess: async (saved) => {
|
||||
const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning };
|
||||
setDraft(config);
|
||||
setSmokeState(undefined);
|
||||
await queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] });
|
||||
setFeedback({ tone: "success", message: "Defaults saved." });
|
||||
},
|
||||
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }),
|
||||
});
|
||||
const smokeMutation = useMutation({
|
||||
mutationFn: runPiManagementTest,
|
||||
onSuccess: (result) => {
|
||||
@@ -379,41 +303,27 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
setFeedback({
|
||||
tone: result.ready ? "success" : "error",
|
||||
message: result.ready
|
||||
? "Saved configuration test passed."
|
||||
: `Saved configuration test failed.${result.message ? ` ${result.message}` : ""}`,
|
||||
? "Catalog default test passed."
|
||||
: `Catalog default test failed.${result.message ? ` ${result.message}` : ""}`,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setSmokeState("failed");
|
||||
setFeedback({ tone: "error", message: errorMessage(error, "Could not test the saved Pi defaults.") });
|
||||
setFeedback({ tone: "error", message: errorMessage(error, "Could not test the catalog default.") });
|
||||
},
|
||||
});
|
||||
|
||||
function saveDefaults() {
|
||||
if (!draft || !validDraft) {
|
||||
setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." });
|
||||
return;
|
||||
}
|
||||
saveMutation.mutate(draft);
|
||||
}
|
||||
|
||||
function updateDraft(update: (current: PiInstallationConfig) => PiInstallationConfig) {
|
||||
setDraft((current) => current ? update(current) : current);
|
||||
setSmokeState(undefined);
|
||||
setFeedback(undefined);
|
||||
}
|
||||
|
||||
function testSavedDefaults() {
|
||||
if (!persistedReady || dirty) {
|
||||
setFeedback({ tone: "error", message: "Save supported defaults before running the test." });
|
||||
function testCatalogDefault() {
|
||||
if (!catalogReady) {
|
||||
setFeedback({ tone: "error", message: "Fix the Installation Model Catalog before running the test." });
|
||||
return;
|
||||
}
|
||||
smokeMutation.mutate();
|
||||
}
|
||||
|
||||
const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden";
|
||||
const loading = statusQuery.isLoading || optionsQuery.isLoading || Boolean(!draft && initialDraft);
|
||||
const unavailable = statusQuery.isError || optionsQuery.isError;
|
||||
const loading = statusQuery.isLoading;
|
||||
const unavailable = statusQuery.isError;
|
||||
|
||||
return (
|
||||
<WorkAreaPanel
|
||||
@@ -421,7 +331,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
ariaLabel="Pi management"
|
||||
eyebrow="Installation controls"
|
||||
title="Pi management"
|
||||
description="Review the bundled runtime, set safe defaults, and test the saved provider configuration."
|
||||
description="Review the bundled runtime, inspect the catalog default, and test its provider configuration."
|
||||
onClose={onClose}
|
||||
closeLabel="Close Pi management"
|
||||
>
|
||||
@@ -430,14 +340,14 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
{forbidden ? (
|
||||
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-semibold">Pi management is not permitted</p>
|
||||
<p className="mt-1 text-muted-foreground">Ask an installation administrator to manage Pi defaults and diagnostics.</p>
|
||||
<p className="mt-1 text-muted-foreground">Ask an installation administrator to manage the model catalog and diagnostics.</p>
|
||||
</div>
|
||||
) : unavailable ? (
|
||||
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-semibold">{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}</p>
|
||||
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry</Button>
|
||||
<p className="font-semibold">{errorMessage(statusQuery.error, "Pi management is unavailable")}</p>
|
||||
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); }}>Retry</Button>
|
||||
</div>
|
||||
) : loading ? <p className="text-sm text-muted-foreground">Loading Pi management…</p> : statusQuery.data && optionsQuery.data && (
|
||||
) : loading ? <p className="text-sm text-muted-foreground">Loading Pi management…</p> : statusQuery.data && (
|
||||
<div className="grid gap-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
@@ -451,7 +361,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
|
||||
<ReadinessRail
|
||||
ready={statusQuery.data.ready}
|
||||
configured={persistedReady}
|
||||
configured={catalogReady}
|
||||
credentials={statusQuery.data.credentials ?? "missing"}
|
||||
smokeState={smokeState}
|
||||
/>
|
||||
@@ -462,56 +372,30 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
|
||||
{feedback.message}
|
||||
</p>}
|
||||
|
||||
<section aria-label="Installation defaults" className="border-t border-border/70 pt-5">
|
||||
<section aria-label="Installation catalog default" className="border-t border-border/70 pt-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<div><h3 className="font-heading text-sm font-semibold">Installation defaults</h3><p className="mt-1 text-sm text-muted-foreground">Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.</p></div>
|
||||
<div><h3 className="font-heading text-sm font-semibold">Installation catalog default</h3><p className="mt-1 text-sm text-muted-foreground">This value is read from <code>modelCatalog.defaults.session</code>. Change the installation YAML and reload Pi; this page never creates a second model default.</p></div>
|
||||
</div>
|
||||
{draft ? (
|
||||
{catalogReady ? (
|
||||
<>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<Field label="Provider">
|
||||
<select aria-label="Provider" className={fieldClass} value={draft.provider} onChange={(event) => {
|
||||
const provider = event.target.value;
|
||||
updateDraft((current) => ({
|
||||
...current,
|
||||
provider,
|
||||
model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "",
|
||||
}));
|
||||
}}>
|
||||
{optionsQuery.data.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Model">
|
||||
<select aria-label="Model" className={fieldClass} value={draft.model} onChange={(event) => updateDraft((current) => ({ ...current, model: event.target.value }))}>
|
||||
{models.map((model) => <option key={model.id} value={model.id}>{model.id === "glm-5.2" ? "GLM 5.2" : model.id}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Reasoning level">
|
||||
<select aria-label="Reasoning level" className={fieldClass} value={draft.reasoning} onChange={(event) => updateDraft((current) => ({ ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] }))}>
|
||||
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-3">
|
||||
<div><p className="thot-label">Provider</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.provider}</p></div>
|
||||
<div><p className="thot-label">Model</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.model}</p></div>
|
||||
<div><p className="thot-label">Reasoning</p><p className="mt-1 font-mono font-semibold">{statusQuery.data.config.reasoning}</p></div>
|
||||
</div>
|
||||
{dirty && <p className="mt-3 text-sm text-amber-800 dark:text-amber-300">
|
||||
{persistedReady ? "Changes are not saved yet." : "Suggested choices are not saved yet."}
|
||||
</p>}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={saveMutation.isPending || !validDraft || !dirty} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button>
|
||||
<Button size="sm" variant="outline" disabled={smokeMutation.isPending || dirty || !persistedReady} onClick={testSavedDefaults}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Test saved defaults</Button>
|
||||
<Button size="sm" variant="outline" disabled={smokeMutation.isPending} onClick={testCatalogDefault}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Test catalog default</Button>
|
||||
</div>
|
||||
{dirty && <p className="mt-2 text-xs text-muted-foreground">Save these changes before testing. The test always uses saved defaults.</p>}
|
||||
{!dirty && !persistedReady && <p className="mt-2 text-xs text-muted-foreground">Save supported defaults before testing. The test always uses saved defaults.</p>}
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
||||
<p className="font-semibold">No provider or model options are available.</p>
|
||||
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
|
||||
<p className="font-semibold">The catalog default is unavailable.</p>
|
||||
<p className="mt-1">Fix <code>modelCatalog.defaults.session</code> in the installation YAML, reload Pi, then retry this panel.</p>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled><ClipboardCheck />Save defaults</Button>
|
||||
<Button size="sm" variant="outline" disabled><FlaskConical />Test saved defaults</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry choices</Button>
|
||||
<Button size="sm" variant="outline" disabled><FlaskConical />Test catalog default</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { void statusQuery.refetch(); }}>Retry status</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -185,58 +185,6 @@ test("a settings preflight from user A prevents session POST after user B logs i
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
test("a workspace-policy preflight from user A prevents session POST after user B logs in", async () => {
|
||||
workspacePreferences.save({
|
||||
workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "low",
|
||||
});
|
||||
let releaseWorkspaces!: () => void;
|
||||
let workspacesStarted!: () => void;
|
||||
let workspacesSettled!: () => void;
|
||||
let sessionPosts = 0;
|
||||
const workspacesGate = new Promise<void>((resolve) => { releaseWorkspaces = resolve; });
|
||||
const started = new Promise<void>((resolve) => { workspacesStarted = resolve; });
|
||||
const settled = new Promise<void>((resolve) => { workspacesSettled = resolve; });
|
||||
server.use(
|
||||
http.get("/api/workspaces", () => HttpResponse.json([{
|
||||
...workspaceSummaryFixture("psd-clinical", {
|
||||
displayName: "PSD Clinical",
|
||||
revision: workspaceRevisionFixture("psd-clinical"),
|
||||
}),
|
||||
}])),
|
||||
http.get("/api/workspaces/psd-clinical", async () => {
|
||||
workspacesStarted();
|
||||
try {
|
||||
await workspacesGate;
|
||||
return HttpResponse.json({
|
||||
workspace: canonicalWorkspaceFixture("psd-clinical"),
|
||||
revision: workspaceRevisionFixture("psd-clinical"),
|
||||
});
|
||||
} finally {
|
||||
workspacesSettled();
|
||||
}
|
||||
}),
|
||||
http.post("/api/sessions", () => {
|
||||
sessionPosts += 1;
|
||||
return HttpResponse.json({ id: "a-session" });
|
||||
}),
|
||||
);
|
||||
const userB = { ...userA, subject: "user-b", csrfToken: "b".repeat(43) };
|
||||
const view = render(<SteerInput sessionId={null} />);
|
||||
const input = screen.getByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(input, "A-policy-question");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
await started;
|
||||
|
||||
act(() => setAuthState(userB));
|
||||
act(() => useSessionStore.getState().setLastUserEntry({ kind: "input", text: "B-entry" }));
|
||||
releaseWorkspaces();
|
||||
await act(async () => { await settled; });
|
||||
|
||||
expect(sessionPosts).toBe(0);
|
||||
expect(useSessionStore.getState().lastUserEntry).toEqual({ kind: "input", text: "B-entry" });
|
||||
view.unmount();
|
||||
});
|
||||
|
||||
test("pressing Enter in the input submits the steer", async () => {
|
||||
let captured: unknown = null;
|
||||
server.use(
|
||||
@@ -338,7 +286,7 @@ test("footer shows cumulative k-token counters after workspace and context gauge
|
||||
expect(thinking.compareDocumentPosition(gauge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test("footer limits model choices to the selected workspace policy", async () => {
|
||||
test("footer exposes every session model from the installation catalog", async () => {
|
||||
server.use(
|
||||
http.get("/api/settings", () => HttpResponse.json({
|
||||
workspace: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
@@ -363,10 +311,10 @@ test("footer limits model choices to the selected workspace policy", async () =>
|
||||
|
||||
const selector = await screen.findByRole("combobox", { name: "Model" });
|
||||
await waitFor(() => expect(selector).toHaveTextContent("GLM-5.2"));
|
||||
await waitFor(() => expect(selector).not.toHaveTextContent("DeepSeek V4 Pro"));
|
||||
await waitFor(() => expect(selector).toHaveTextContent("DeepSeek V4 Pro"));
|
||||
});
|
||||
|
||||
test("switching workspaces replaces an out-of-policy model before session creation", async () => {
|
||||
test("switching workspaces preserves a globally available catalog model", async () => {
|
||||
let body: unknown;
|
||||
workspacePreferences.save({
|
||||
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
@@ -405,119 +353,14 @@ test("switching workspaces replaces an out-of-policy model before session creati
|
||||
const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" });
|
||||
await waitFor(() => expect(workspaceSelector).toHaveTextContent("psd-clinical"));
|
||||
await userEvent.selectOptions(workspaceSelector, "psd-clinical");
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("glm-5.2"));
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).not.toHaveTextContent("DeepSeek V4 Pro");
|
||||
await waitFor(() => expect(screen.getByRole("combobox", { name: "Model" })).toHaveValue("deepseek-v4-pro"));
|
||||
expect(screen.getByRole("combobox", { name: "Model" })).toHaveTextContent("DeepSeek V4 Pro");
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
await waitFor(() => expect(body).toEqual({
|
||||
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
|
||||
test("immediate submit waits for a switched workspace policy before creating a session", async () => {
|
||||
let body: unknown;
|
||||
let releasePolicy!: () => void;
|
||||
let policyRequestStarted = false;
|
||||
const policyMayFinish = new Promise<void>((resolve) => { releasePolicy = resolve; });
|
||||
workspacePreferences.save({
|
||||
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
});
|
||||
const revision = (id: string) => ({
|
||||
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot",
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([
|
||||
workspaceSummaryFixture("research", { displayName: "Research", revision: revision("research") }),
|
||||
workspaceSummaryFixture("psd-clinical", { displayName: "PSD Clinical", revision: revision("psd-clinical") }),
|
||||
])),
|
||||
http.get("/api/workspaces/research", () => HttpResponse.json({
|
||||
workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"),
|
||||
})),
|
||||
http.get("/api/workspaces/psd-clinical", async () => {
|
||||
policyRequestStarted = true;
|
||||
await policyMayFinish;
|
||||
return HttpResponse.json({
|
||||
workspace: canonicalWorkspaceFixture("psd-clinical", ["zai/glm-5.2"], "zai/glm-5.2"),
|
||||
revision: revision("psd-clinical"),
|
||||
});
|
||||
}),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [
|
||||
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
||||
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
|
||||
] })),
|
||||
http.post("/api/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "s1" });
|
||||
}),
|
||||
);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><ComposerFooter /><SteerInput sessionId={null} /></QueryClientProvider>);
|
||||
|
||||
const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" });
|
||||
await waitFor(() => expect(workspaceSelector).toHaveTextContent("psd-clinical"));
|
||||
await userEvent.selectOptions(workspaceSelector, "psd-clinical");
|
||||
await waitFor(() => expect(policyRequestStarted).toBe(true));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(body).toBeUndefined();
|
||||
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
|
||||
releasePolicy();
|
||||
|
||||
await waitFor(() => expect(body).toEqual({
|
||||
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
|
||||
test("initial restored workspace waits for its delayed policy before creating a session", async () => {
|
||||
let body: unknown;
|
||||
let releasePolicy!: () => void;
|
||||
let policyRequestStarted = false;
|
||||
const policyMayFinish = new Promise<void>((resolve) => { releasePolicy = resolve; });
|
||||
workspacePreferences.save({
|
||||
workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
});
|
||||
const revision = (id: string) => ({
|
||||
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot",
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "psd-clinical" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([
|
||||
workspaceSummaryFixture("psd-clinical", { displayName: "PSD Clinical", revision: revision("psd-clinical") }),
|
||||
])),
|
||||
http.get("/api/workspaces/psd-clinical", async () => {
|
||||
policyRequestStarted = true;
|
||||
await policyMayFinish;
|
||||
return HttpResponse.json({
|
||||
workspace: canonicalWorkspaceFixture("psd-clinical", ["zai/glm-5.2"], "zai/glm-5.2"),
|
||||
revision: revision("psd-clinical"),
|
||||
});
|
||||
}),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [
|
||||
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
||||
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
|
||||
] })),
|
||||
http.post("/api/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "s1" });
|
||||
}),
|
||||
);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><ComposerFooter /><SteerInput sessionId={null} /></QueryClientProvider>);
|
||||
|
||||
await waitFor(() => expect(policyRequestStarted).toBe(true));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(body).toBeUndefined();
|
||||
expect(screen.getByRole("button", { name: /send/i })).toBeDisabled();
|
||||
releasePolicy();
|
||||
|
||||
await waitFor(() => expect(body).toEqual({
|
||||
question: "q", workspaceId: "psd-clinical", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
question: "q", workspaceId: "psd-clinical", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -591,70 +434,6 @@ test("failed workspace summaries block creation and report a safe error", async
|
||||
expect(body).toBeUndefined();
|
||||
});
|
||||
|
||||
test("submit follows a rapid workspace switch instead of waiting for an abandoned policy", async () => {
|
||||
let body: unknown;
|
||||
let releaseC!: () => void;
|
||||
let bPolicyRequestStarted = false;
|
||||
let cPolicyRequestStarted = false;
|
||||
const cPolicyMayFinish = new Promise<void>((resolve) => { releaseC = resolve; });
|
||||
workspacePreferences.save({
|
||||
workspaceId: "research", provider: "deepseek", model: "deepseek-v4-pro", thinking: "medium",
|
||||
});
|
||||
const revision = (id: string) => ({
|
||||
id, commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/snapshot",
|
||||
});
|
||||
server.use(
|
||||
http.get("/api/settings", () => HttpResponse.json({ workspace: "research" })),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([
|
||||
workspaceSummaryFixture("research", { displayName: "Research", revision: revision("research") }),
|
||||
workspaceSummaryFixture("workspace-b", { displayName: "Workspace B", revision: revision("workspace-b") }),
|
||||
workspaceSummaryFixture("workspace-c", { displayName: "Workspace C", revision: revision("workspace-c") }),
|
||||
])),
|
||||
http.get("/api/workspaces/research", () => HttpResponse.json({
|
||||
workspace: canonicalWorkspaceFixture("research", ["deepseek/deepseek-v4-pro"]), revision: revision("research"),
|
||||
})),
|
||||
http.get("/api/workspaces/workspace-b", async () => {
|
||||
bPolicyRequestStarted = true;
|
||||
await new Promise(() => undefined);
|
||||
return HttpResponse.json({});
|
||||
}),
|
||||
http.get("/api/workspaces/workspace-c", async () => {
|
||||
cPolicyRequestStarted = true;
|
||||
await cPolicyMayFinish;
|
||||
return HttpResponse.json({
|
||||
workspace: canonicalWorkspaceFixture("workspace-c", ["zai/glm-5.2"], "zai/glm-5.2"),
|
||||
revision: revision("workspace-c"),
|
||||
});
|
||||
}),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [
|
||||
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
|
||||
{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", reasoning: true },
|
||||
] })),
|
||||
http.post("/api/sessions", async ({ request }) => {
|
||||
body = await request.json();
|
||||
return HttpResponse.json({ id: "s1" });
|
||||
}),
|
||||
);
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(<QueryClientProvider client={client}><ComposerFooter /><SteerInput sessionId={null} /></QueryClientProvider>);
|
||||
|
||||
const workspaceSelector = await screen.findByRole("combobox", { name: "Workspace" });
|
||||
await waitFor(() => expect(screen.getByRole("option", { name: "workspace-b" })).toBeInTheDocument());
|
||||
await userEvent.selectOptions(workspaceSelector, "workspace-b");
|
||||
await waitFor(() => expect(bPolicyRequestStarted).toBe(true));
|
||||
await userEvent.type(screen.getByRole("textbox", { name: /new question/i }), "q");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
await userEvent.selectOptions(workspaceSelector, "workspace-c");
|
||||
await waitFor(() => expect(cPolicyRequestStarted).toBe(true));
|
||||
|
||||
expect(body).toBeUndefined();
|
||||
releaseC();
|
||||
|
||||
await waitFor(() => expect(body).toEqual({
|
||||
question: "q", workspaceId: "workspace-c", provider: "zai", model: "glm-5.2", thinking: "medium",
|
||||
}));
|
||||
});
|
||||
|
||||
test("pulses the stop dot only while the harness is working", () => {
|
||||
const { rerender } = render(<SteerInput sessionId="s1" working />);
|
||||
const dot = () =>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { postSteer, createSession } from "../api/sessions";
|
||||
import { ApiError, apiErrorMessage } from "../api/client";
|
||||
import { getSettings } from "../api/settings";
|
||||
import { getWorkspace, listWorkspaces } from "../api/workspaces";
|
||||
import { listWorkspaces } from "../api/workspaces";
|
||||
import { listModels } from "../api/models";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import {
|
||||
@@ -202,18 +202,10 @@ export function ComposerFooter() {
|
||||
const workspace = preferences.workspaceId ?? settings?.workspace ?? "";
|
||||
const selectedWorkspace = workspaces.find((candidate) => candidate.id === workspace);
|
||||
const selectedWorkspaceHasRevision = Boolean(selectedWorkspace?.revision);
|
||||
const { data: workspaceRecord, isError: workspacePolicyError } = useQuery({
|
||||
queryKey: ["workspace", workspace],
|
||||
queryFn: () => getWorkspace(workspace),
|
||||
enabled: selectedWorkspaceHasRevision,
|
||||
});
|
||||
const model = preferences.model ?? settings?.model ?? "";
|
||||
const thinking = preferences.thinking ?? settings?.thinking ?? "medium";
|
||||
|
||||
const allowedModels = workspaceRecord?.workspace.llm_policy.allowed;
|
||||
const policyModels = allowedModels
|
||||
? models.filter((candidate) => allowedModels.includes(`${candidate.provider}/${candidate.id}`))
|
||||
: models;
|
||||
const policyModels = models;
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) {
|
||||
@@ -226,38 +218,12 @@ export function ComposerFooter() {
|
||||
workspacePolicyGate.rejectSummary(workspace);
|
||||
} else if (selectedWorkspace?.revision) {
|
||||
workspacePolicyGate.select(workspace);
|
||||
workspacePolicyGate.resolve(workspace);
|
||||
} else {
|
||||
workspacePolicyGate.allowLegacy(workspace);
|
||||
}
|
||||
}, [selectedWorkspace, workspace, workspaceSummariesError, workspacesLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowedModels?.length) return;
|
||||
const selected = preferences.provider && preferences.model
|
||||
? `${preferences.provider}/${preferences.model}`
|
||||
: undefined;
|
||||
if (selected && allowedModels.some((allowed) => allowed === selected)) return;
|
||||
const replacement = workspaceRecord?.workspace.llm_policy.default
|
||||
&& allowedModels.includes(workspaceRecord.workspace.llm_policy.default)
|
||||
? workspaceRecord.workspace.llm_policy.default
|
||||
: allowedModels[0];
|
||||
const separator = replacement.indexOf("/");
|
||||
if (separator <= 0 || separator === replacement.length - 1) return;
|
||||
const next = {
|
||||
...preferences,
|
||||
provider: replacement.slice(0, separator),
|
||||
model: replacement.slice(separator + 1),
|
||||
};
|
||||
workspacePreferences.save(next);
|
||||
setPreferences(next);
|
||||
}, [allowedModels, preferences, workspaceRecord]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedWorkspace?.revision) return;
|
||||
if (workspaceRecord) workspacePolicyGate.resolve(workspace);
|
||||
else if (workspacePolicyError) workspacePolicyGate.reject(workspace);
|
||||
}, [selectedWorkspace, workspace, workspacePolicyError, workspaceRecord]);
|
||||
|
||||
function update(patch: WorkspacePreference) {
|
||||
if (patch.workspaceId && patch.workspaceId !== workspace) {
|
||||
const selected = workspaces.find((candidate) => candidate.id === patch.workspaceId);
|
||||
@@ -275,7 +241,7 @@ export function ComposerFooter() {
|
||||
}
|
||||
|
||||
const knownModel = policyModels.some((m) => m.id === model);
|
||||
const showModelFallback = !allowedModels && !knownModel;
|
||||
const showModelFallback = !knownModel;
|
||||
const contextPct = tokenUsage && tokenUsage.contextWindow > 0
|
||||
? tokenUsage.totalTokens / tokenUsage.contextWindow
|
||||
: 0;
|
||||
|
||||
@@ -2,24 +2,15 @@ import type { CanonicalWorkspace, WorkspaceRevision, WorkspaceSummary } from "..
|
||||
|
||||
export function canonicalWorkspaceFixture(
|
||||
id: string,
|
||||
allowed: `${string}/${string}`[] = ["zai/glm-5.2"],
|
||||
defaultModel?: `${string}/${string}`,
|
||||
_allowed: `${string}/${string}`[] = ["zai/glm-5.2"],
|
||||
_defaultModel?: `${string}/${string}`,
|
||||
): CanonicalWorkspace {
|
||||
return {
|
||||
workspace: { schema_version: 3, id, name: id, language: "en" },
|
||||
workspace: { schema_version: 4, id, name: id, language: "en" },
|
||||
dwh: {
|
||||
engine: "postgres", database: "database", schema: "public",
|
||||
supported_transports: ["postgres_direct"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant", collection: id, dimensions: 1024, distance: "cosine",
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024,
|
||||
},
|
||||
},
|
||||
llm_policy: { ...(defaultModel ? { default: defaultModel } : {}), allowed },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -130,10 +130,6 @@ function workspaceId(value: unknown): string | undefined {
|
||||
return typeof value === "string" && /^[a-z][a-z0-9-]{2,62}$/.test(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function modelReference(value: unknown): `${string}/${string}` | undefined {
|
||||
return typeof value === "string" && /^[^/\s]+\/[^/\s]+$/.test(value) ? value as `${string}/${string}` : undefined;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, max = Number.MAX_SAFE_INTEGER): number | undefined {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 && value <= max ? value : undefined;
|
||||
}
|
||||
@@ -348,17 +344,6 @@ function uniqueChoices<T extends string>(value: unknown, choices: readonly T[]):
|
||||
return result;
|
||||
}
|
||||
|
||||
function uniqueModels(value: unknown): `${string}/${string}`[] | undefined {
|
||||
if (!Array.isArray(value) || value.length === 0) return undefined;
|
||||
const result: `${string}/${string}`[] = [];
|
||||
for (const item of value) {
|
||||
const model = modelReference(item);
|
||||
if (!model || result.includes(model)) return undefined;
|
||||
result.push(model);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function originRelativePath(value: unknown): string | undefined {
|
||||
return typeof value === "string" && /^\/(?!\/)[^\\\u0000-\u001F\u007F?#]*$/.test(value) && !/%5c/i.test(value)
|
||||
? value
|
||||
@@ -391,17 +376,11 @@ function copyDiagnostics(value: unknown): CanonicalDiagnostics | undefined {
|
||||
|
||||
/** Drops unknown fields before a server response can become a browser draft or conflict view. */
|
||||
export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace | undefined {
|
||||
const source = exactRecord(value, [
|
||||
"workspace", "dwh", "semantic_index", "llm_policy", "diagnostics", "evidence",
|
||||
]);
|
||||
const source = exactRecord(value, ["workspace", "dwh", "diagnostics", "evidence"]);
|
||||
const metadata = exactRecord(source?.workspace, ["schema_version", "id", "name", "description", "language"]);
|
||||
const dwh = exactRecord(source?.dwh, ["engine", "database", "schema", "port", "timeout_ms", "supported_transports"]);
|
||||
const semanticIndex = exactRecord(source?.semantic_index, ["vector_store", "embedding"]);
|
||||
const vectorStore = exactRecord(semanticIndex?.vector_store, ["engine", "collection", "dimensions", "distance"]);
|
||||
const embedding = exactRecord(semanticIndex?.embedding, ["provider", "model", "dimensions"]);
|
||||
const policy = exactRecord(source?.llm_policy, ["default", "allowed"]);
|
||||
const diagnostics = source?.diagnostics === undefined ? undefined : copyDiagnostics(source.diagnostics);
|
||||
if (!metadata || !dwh || !semanticIndex || !vectorStore || !embedding || !policy) return undefined;
|
||||
if (!metadata || !dwh) return undefined;
|
||||
const id = workspaceId(metadata.id);
|
||||
const evidence = id && source?.evidence !== undefined ? copyEvidence(source.evidence, id) : undefined;
|
||||
const name = text(metadata.name);
|
||||
@@ -412,29 +391,16 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
||||
const dwhPort = dwh.port === undefined ? undefined : positiveInteger(dwh.port, 65_535);
|
||||
const dwhTimeout = dwh.timeout_ms === undefined ? undefined : positiveInteger(dwh.timeout_ms);
|
||||
const dwhTransports = uniqueChoices(dwh.supported_transports, ["postgres_direct", "rest_api", "ssh_tunnel"] as const);
|
||||
const collection = workspaceId(vectorStore.collection);
|
||||
const vectorDimensions = positiveInteger(vectorStore.dimensions, 32_768);
|
||||
const distance = oneOf(vectorStore.distance, ["cosine"] as const);
|
||||
const embeddingProvider = oneOf(embedding.provider, ["ollama_internal"] as const);
|
||||
const embeddingModel = text(embedding.model);
|
||||
const embeddingDimensions = positiveInteger(embedding.dimensions, 32_768);
|
||||
const allowedModels = uniqueModels(policy.allowed);
|
||||
const defaultModel = policy.default === undefined ? undefined : modelReference(policy.default);
|
||||
if (
|
||||
metadata.schema_version !== 3 || !id || !name || !language || (metadata.description !== undefined && !description)
|
||||
metadata.schema_version !== 4 || !id || !name || !language || (metadata.description !== undefined && !description)
|
||||
|| dwh.engine !== "postgres" || !database || !schema || (dwh.port !== undefined && !dwhPort) || (dwh.timeout_ms !== undefined && !dwhTimeout) || !dwhTransports
|
||||
|| vectorStore.engine !== "qdrant" || !collection || !vectorDimensions || !distance
|
||||
|| !embeddingProvider || !embeddingModel || !embeddingDimensions || !allowedModels
|
||||
|| (defaultModel !== undefined && !allowedModels.includes(defaultModel)) || vectorDimensions !== embeddingDimensions
|
||||
|| vectorDimensions !== 1024 || embeddingDimensions !== 1024
|
||||
|| embeddingModel !== "qwen3-embedding:0.6b"
|
||||
) return undefined;
|
||||
if (source?.diagnostics !== undefined && !diagnostics) return undefined;
|
||||
if (source?.evidence !== undefined && !evidence) return undefined;
|
||||
if (diagnostics?.dwh_rest && !dwhTransports.includes("rest_api")) return undefined;
|
||||
return {
|
||||
workspace: {
|
||||
schema_version: 3,
|
||||
schema_version: 4,
|
||||
id,
|
||||
name,
|
||||
...(description ? { description } : {}),
|
||||
@@ -446,17 +412,6 @@ export function sanitizeCanonicalWorkspace(value: unknown): CanonicalWorkspace |
|
||||
...(dwhTimeout ? { timeout_ms: dwhTimeout } : {}),
|
||||
supported_transports: dwhTransports,
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant", collection, dimensions: 1024, distance: "cosine",
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_internal", model: "qwen3-embedding:0.6b", dimensions: 1024,
|
||||
},
|
||||
},
|
||||
llm_policy: {
|
||||
...(defaultModel ? { default: defaultModel } : {}), allowed: allowedModels,
|
||||
},
|
||||
...(diagnostics ? { diagnostics } : {}),
|
||||
...(evidence ? { evidence } : {}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user