docs: plan Pi-enabled model selector

This commit is contained in:
User
2026-07-14 17:51:16 +02:00
parent 49f6a402ee
commit 7a07d9491f
@@ -0,0 +1,918 @@
# Pi-Enabled Model Selector Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Restore a usable model selector that exposes exactly GLM 5.2, DeepSeek V4 Flash, and local Qwen3.6 from Pi's effective `enabledModels` configuration.
**Architecture:** Add a focused Pi-settings reader that resolves global/project precedence and returns exact composite model IDs. The ephemeral Pi lister will run with a scrubbed, provider-neutral environment, intersect Pi's available catalog with those IDs, and keep the existing API shape; session spawning will explicitly treat `local-qwen` as local.
**Tech Stack:** Node.js 22, TypeScript, Fastify, Pi RPC, Vitest, React 18, Testing Library/MSW, Docker Compose.
## Global Constraints
- The selector exposes exactly `zai/glm-5.2`, `deepseek/deepseek-v4-flash`, and `local-qwen/qwen3.6-35b-a3b`.
- `zai/glm-5v-turbo` remains hidden.
- Pi `enabledModels` is the single source of truth; do not add an application allowlist.
- Only exact `provider/model` entries are accepted; do not expand wildcard, fuzzy, model-only, or thinking-suffixed patterns.
- Missing or malformed model scope fails closed to an empty model list and emits only sanitized warnings.
- Do not expose credential values or `auth.json` contents in code, tests, logs, or command output.
- Preserve hosted-provider credential isolation and reject unknown providers.
- UI strings remain English.
- Backend gates are `npx vitest run` and `npx tsc --noEmit -p .`.
- Frontend gates are `npx vitest run` and `npx tsc -b`.
- Completion requires rebuilding and recreating the impacted Docker container, not merely building source assets.
---
## File map
- Create `backend/src/pi/enabled-models.ts`: load and validate effective Pi `enabledModels`.
- Create `backend/test/enabled-models.test.ts`: precedence, validation, and fail-closed unit tests.
- Modify `backend/src/pi/list-models.ts`: provider-neutral Pi spawn, enabled-model intersection, warning callback.
- Modify `backend/test/list-models.test.ts`: regression, ordering, filtering, caching, and scrubbed-environment tests.
- Modify `backend/src/routes/settings.ts`: validate the composite `provider/model` pair.
- Modify `backend/src/routes/meta.ts`: sanitized warning when the lister fails.
- Modify `backend/src/app.ts`: wire model-scope warnings into the Fastify logger.
- Modify `backend/test/routes-settings.test.ts`: duplicate-ID/different-provider validation regression.
- Modify `backend/src/pi/provider-credentials.ts`: classify `local-qwen` as local.
- Modify `backend/test/provider-credentials.test.ts`: direct custom-local credential-policy test.
- Modify `backend/test/pi-process-manager.test.ts`: session-spawn coverage for `local-qwen`.
- Modify `frontend/src/shell/AppShell.new-session.test.tsx`: render and select the three-model response.
- Modify `/home/chirone/thothii-data/pi-config/agent/settings.json`: remove live GLM-5V from `enabledModels`.
- Modify `PROJECT_STATE.md`: record the regression fix, verification counts, deployment time, and image/container digest.
---
### Task 1: Effective Pi `enabledModels` reader
**Files:**
- Create: `backend/src/pi/enabled-models.ts`
- Test: `backend/test/enabled-models.test.ts`
**Interfaces:**
- Consumes: `harnessDir`, optional `agentDir`, and an injectable `(path: string) => string` reader.
- Produces: `loadPiEnabledModels(opts): PiEnabledModelsResult`, where the result is `{ ids: string[]; warnings: string[]; source?: string }`.
- [ ] **Step 1: Write the failing settings-reader tests**
```ts
import { expect, test } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { loadPiEnabledModels } from "../src/pi/enabled-models.js";
function fixture(globalValue: unknown, projectValue?: unknown) {
const root = mkdtempSync(join(tmpdir(), "tht-enabled-models-"));
const agentDir = join(root, "agent");
const harnessDir = join(root, "harness");
mkdirSync(agentDir, { recursive: true });
mkdirSync(join(harnessDir, ".pi"), { recursive: true });
writeFileSync(join(agentDir, "settings.json"), JSON.stringify(globalValue));
if (projectValue !== undefined) {
writeFileSync(join(harnessDir, ".pi", "settings.json"), JSON.stringify(projectValue));
}
return { root, agentDir, harnessDir };
}
test("loads exact global enabledModels in configured order", () => {
const f = fixture({ enabledModels: [
"zai/glm-5.2",
"deepseek/deepseek-v4-flash",
"local-qwen/qwen3.6-35b-a3b",
] });
try {
expect(loadPiEnabledModels(f)).toMatchObject({
ids: [
"zai/glm-5.2",
"deepseek/deepseek-v4-flash",
"local-qwen/qwen3.6-35b-a3b",
],
warnings: [],
});
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("project enabledModels overrides global enabledModels", () => {
const f = fixture(
{ enabledModels: ["zai/glm-5.2", "zai/glm-5v-turbo"] },
{ enabledModels: ["local-qwen/qwen3.6-35b-a3b"] },
);
try {
expect(loadPiEnabledModels(f).ids).toEqual(["local-qwen/qwen3.6-35b-a3b"]);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("project settings without enabledModels fall back to global settings", () => {
const f = fixture({ enabledModels: ["zai/glm-5.2"] }, { theme: "thothii-mono" });
try {
expect(loadPiEnabledModels(f).ids).toEqual(["zai/glm-5.2"]);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("invalid entries are ignored, deduplicated, and reported without their content", () => {
const f = fixture({ enabledModels: [
"zai/glm-5.2", "zai/glm-5.2", "glm-5.2", "zai/*", "zai/glm-5.2:high", 7,
] });
try {
const result = loadPiEnabledModels(f);
expect(result.ids).toEqual(["zai/glm-5.2"]);
expect(result.warnings).toHaveLength(4);
expect(result.warnings.join(" ")).not.toContain("glm-5.2:high");
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test.each([
["missing field", {}],
["empty field", { enabledModels: [] }],
["wrong type", { enabledModels: "zai/glm-5.2" }],
])("%s fails closed", (_name, settings) => {
const f = fixture(settings);
try {
const result = loadPiEnabledModels(f);
expect(result.ids).toEqual([]);
expect(result.warnings.length).toBeGreaterThan(0);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("malformed project settings fail closed instead of exposing global models", () => {
const f = fixture({ enabledModels: ["zai/glm-5.2"] });
writeFileSync(join(f.harnessDir, ".pi", "settings.json"), "{");
try {
const result = loadPiEnabledModels(f);
expect(result.ids).toEqual([]);
expect(result.warnings).toHaveLength(1);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
test("missing global settings fail closed", () => {
const f = fixture({ enabledModels: ["zai/glm-5.2"] });
rmSync(join(f.agentDir, "settings.json"));
try {
const result = loadPiEnabledModels(f);
expect(result.ids).toEqual([]);
expect(result.warnings).toHaveLength(1);
} finally { rmSync(f.root, { recursive: true, force: true }); }
});
```
- [ ] **Step 2: Run the focused test and verify the missing module failure**
Run: `cd backend && npx vitest run test/enabled-models.test.ts`
Expected: FAIL because `../src/pi/enabled-models.js` does not exist.
- [ ] **Step 3: Implement the effective settings reader**
```ts
import { readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export interface PiEnabledModelsResult {
ids: string[];
warnings: string[];
source?: string;
}
interface LoadOptions {
harnessDir: string;
agentDir?: string;
read?: (path: string) => string;
}
type Settings = Record<string, unknown>;
const INVALID = Symbol("invalid-settings");
function readSettings(
path: string,
optional: boolean,
read: (path: string) => string,
warnings: string[],
): Settings | undefined | typeof INVALID {
try {
const value = JSON.parse(read(path));
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
return value as Settings;
} catch (error) {
if (optional && (error as NodeJS.ErrnoException)?.code === "ENOENT") return undefined;
warnings.push(`Pi settings are unreadable or invalid: ${path}`);
return INVALID;
}
}
function isExactCompositeId(value: unknown): value is string {
if (typeof value !== "string" || /[\s*?:]/.test(value)) return false;
const parts = value.split("/");
return parts.length === 2 && parts.every((part) => part.length > 0);
}
export function loadPiEnabledModels(opts: LoadOptions): PiEnabledModelsResult {
const warnings: string[] = [];
const read = opts.read ?? ((path: string) => readFileSync(path, "utf8"));
const agentDir = opts.agentDir ?? join(homedir(), ".pi", "agent");
const globalPath = join(agentDir, "settings.json");
const projectPath = join(opts.harnessDir, ".pi", "settings.json");
const globalSettings = readSettings(globalPath, false, read, warnings);
const projectSettings = readSettings(projectPath, true, read, warnings);
if (globalSettings === INVALID || projectSettings === INVALID) return { ids: [], warnings };
const projectDefinesScope = projectSettings
? Object.prototype.hasOwnProperty.call(projectSettings, "enabledModels")
: false;
const settings = projectDefinesScope ? projectSettings : globalSettings;
const source = projectDefinesScope ? projectPath : globalPath;
const raw = settings?.enabledModels;
if (!Array.isArray(raw) || raw.length === 0) {
warnings.push(`Pi enabledModels is missing or empty: ${source}`);
return { ids: [], warnings, source };
}
const ids: string[] = [];
const seen = new Set<string>();
raw.forEach((value, index) => {
if (!isExactCompositeId(value)) {
warnings.push(`Ignoring non-exact enabledModels entry at index ${index}: ${source}`);
return;
}
if (!seen.has(value)) {
seen.add(value);
ids.push(value);
}
});
if (ids.length === 0) warnings.push(`Pi enabledModels contains no exact model IDs: ${source}`);
return { ids, warnings, source };
}
```
- [ ] **Step 4: Run the reader tests and type-check**
Run: `cd backend && npx vitest run test/enabled-models.test.ts && npx tsc --noEmit -p .`
Expected: all focused tests PASS and TypeScript exits 0.
- [ ] **Step 5: Commit the reader**
```bash
git add backend/src/pi/enabled-models.ts backend/test/enabled-models.test.ts
git commit -m "feat(backend): read Pi enabled model scope"
```
---
### Task 2: Provider-neutral Pi listing and exact scope filtering
**Files:**
- Modify: `backend/src/pi/list-models.ts`
- Modify: `backend/test/list-models.test.ts`
**Interfaces:**
- Consumes: `loadPiEnabledModels({ harnessDir })` from Task 1 and Pi RPC `get_available_models`.
- Produces: `createPiModelLister(cfg, opts): () => Promise<PiModel[]>`; new options are `loadEnabledModels?: () => PiEnabledModelsResult` and `warn?: (message: string) => void`.
- [ ] **Step 1: Replace provider-specific lister expectations with failing regression tests**
Add this helper near the top of `backend/test/list-models.test.ts`. In the first mapping
test pass both `zai/glm-5.2` and `anthropic/claude-opus-4-8`; in the cache test pass
`zai/glm-5.2`; in environment-only tests whose fake catalog is empty pass
`test/unavailable` so Pi still spawns. This keeps every existing lister test independent
of the developer's home directory:
```ts
function enabled(...ids: string[]) {
return () => ({ ids, warnings: [], source: "/test/settings.json" });
}
```
Remove the obsolete lister-only tests that expect enumeration to inject the selected
provider key or reject compound providers. Session credential isolation remains
covered by `provider-credentials.test.ts` and `pi-process-manager.test.ts`.
Add these regression tests:
```ts
test("model listing does not require PI_PROVIDER or read the generic credential", async () => {
const script = scriptWith([
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
]);
const calls: any[][] = [];
try {
const lister = createPiModelLister(loadConfig({
PI_BIN: "/usr/local/bin/pi",
THT_MODEL_API_KEY_FILE: "/missing-and-must-not-be-read",
}), {
loadEnabledModels: enabled("zai/glm-5.2"),
spawnFn: (...args: any[]) => {
calls.push(args);
return spawn("node", [FAKE, script]) as any;
},
});
await expect(lister()).resolves.toHaveLength(1);
expect(calls[0][2].env).not.toHaveProperty("ZAI_API_KEY");
expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
} finally { rmSync(path.dirname(script), { recursive: true, force: true }); }
});
test("returns only enabled available models in enabledModels order", async () => {
const script = scriptWith([
{ provider: "zai", id: "glm-5v-turbo", name: "GLM-5V-Turbo", reasoning: true },
{ provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 Local", reasoning: false },
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
{ provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true },
]);
try {
const lister = createPiModelLister(loadConfig({}), {
loadEnabledModels: enabled(
"zai/glm-5.2",
"deepseek/deepseek-v4-flash",
"local-qwen/qwen3.6-35b-a3b",
),
spawnFn: () => spawn("node", [FAKE, script]) as any,
});
expect((await lister()).map((m) => `${m.provider}/${m.id}`)).toEqual([
"zai/glm-5.2",
"deepseek/deepseek-v4-flash",
"local-qwen/qwen3.6-35b-a3b",
]);
} finally { rmSync(path.dirname(script), { recursive: true, force: true }); }
});
test("empty enabled model scope fails closed without spawning Pi", async () => {
let spawns = 0;
const warnings: string[] = [];
const lister = createPiModelLister(loadConfig({}), {
loadEnabledModels: () => ({ ids: [], warnings: ["scope invalid"] }),
warn: (message) => warnings.push(message),
spawnFn: () => { spawns += 1; throw new Error("must not spawn"); },
});
await expect(lister()).resolves.toEqual([]);
expect(spawns).toBe(0);
expect(warnings).toEqual(["scope invalid"]);
});
test("warns and returns empty when enabled identifiers are unavailable", async () => {
const script = scriptWith([
{ provider: "zai", id: "glm-5v-turbo", name: "GLM-5V-Turbo", reasoning: true },
]);
const warnings: string[] = [];
try {
const lister = createPiModelLister(loadConfig({}), {
loadEnabledModels: enabled("zai/glm-5.2"),
warn: (message) => warnings.push(message),
spawnFn: () => spawn("node", [FAKE, script]) as any,
});
await expect(lister()).resolves.toEqual([]);
expect(warnings).toEqual(["No Pi-enabled models are currently available"]);
} finally { rmSync(path.dirname(script), { recursive: true, force: true }); }
});
```
- [ ] **Step 2: Run the lister tests and verify the old implementation fails**
Run: `cd backend && npx vitest run test/list-models.test.ts`
Expected: FAIL because the lister still requires the configured provider credential,
does not accept `loadEnabledModels`, and does not filter/order the response.
- [ ] **Step 3: Implement provider-neutral enumeration and filtering**
In `backend/src/pi/list-models.ts`, remove the `secretValue` import, import
`loadPiEnabledModels` and its result type, extend `Opts`, and use the following core
flow:
```ts
import {
loadPiEnabledModels,
type PiEnabledModelsResult,
} from "./enabled-models.js";
interface Opts {
spawnFn?: (
command: string,
args: string[],
options: { cwd: string; env: NodeJS.ProcessEnv },
) => ChildProcessWithoutNullStreams;
ttlMs?: number;
nowMs?: () => number;
loadEnabledModels?: () => PiEnabledModelsResult;
warn?: (message: string) => void;
}
// Inside listModels(), before spawning Pi:
const enabled = (opts.loadEnabledModels
?? (() => loadPiEnabledModels({ harnessDir: cfg.harnessDir })))();
for (const warning of enabled.warnings) opts.warn?.(warning);
if (enabled.ids.length === 0) {
cache = { at: now(), models: [] };
return [];
}
const env = buildPiChildEnv({});
delete env.THT_DATA_ROOT;
if (cfg.dataRoot !== undefined) env.THT_DATA_ROOT = cfg.dataRoot;
// After mapping the RPC response to `available: PiModel[]`:
const byCompositeId = new Map(
available.map((model) => [`${model.provider}/${model.id}`, model]),
);
const models = enabled.ids.flatMap((id) => {
const model = byCompositeId.get(id);
return model ? [model] : [];
});
if (models.length === 0) opts.warn?.("No Pi-enabled models are currently available");
cache = { at: now(), models };
return models;
```
Keep the existing `finally { child.kill(); }`, eight-second timeout, response mapping,
TTL behavior, cwd, and `THT_DATA_ROOT` handling unchanged.
- [ ] **Step 4: Run focused and neighboring backend tests**
Run: `cd backend && npx vitest run test/enabled-models.test.ts test/list-models.test.ts test/provider-credentials.test.ts`
Expected: all tests PASS.
- [ ] **Step 5: Commit the lister regression fix**
```bash
git add backend/src/pi/list-models.ts backend/test/list-models.test.ts
git commit -m "fix(backend): scope Pi model listing to enabled models"
```
---
### Task 3: Composite settings validation and sanitized observability
**Files:**
- Modify: `backend/src/routes/settings.ts`
- Modify: `backend/src/routes/meta.ts`
- Modify: `backend/src/app.ts`
- Modify: `backend/test/routes-settings.test.ts`
- Modify: `backend/test/routes-sql-meta.test.ts`
**Interfaces:**
- Consumes: filtered `ListModelsFn` results containing `{ provider, id }`.
- Produces: `PUT /settings` validation by composite ID and warning-only Fastify logs for model-list failures.
- [ ] **Step 1: Write the failing composite-validation test**
```ts
test("PUT /settings validates provider and model as one composite identifier", async () => {
const { app, dir } = appWithTmpSettings({}, {
listModels: async () => [
{ provider: "provider-a", id: "shared-id", name: "A", reasoning: false },
],
});
try {
const wrongProvider = await app.inject({
method: "PUT", url: "/settings",
payload: {
workspace: "psd", provider: "provider-b", model: "shared-id", thinking: "low",
},
});
expect(wrongProvider.statusCode).toBe(400);
const exactPair = await app.inject({
method: "PUT", url: "/settings",
payload: {
workspace: "psd", provider: "provider-a", model: "shared-id", thinking: "low",
},
});
expect(exactPair.statusCode).toBe(200);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
```
In `routes-sql-meta.test.ts`, spy on the application logger and assert the graceful
fallback logs only an error type, not the thrown message:
```ts
test("GET /models logs a sanitized warning when listing fails", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {} as any,
listModels: async () => { throw new Error("credential-value-must-not-appear"); },
});
const warn = vi.spyOn(app.log, "warn");
const res = await app.inject({ method: "GET", url: "/models" });
expect(res.json()).toEqual({ models: [] });
expect(JSON.stringify(warn.mock.calls)).not.toContain("credential-value-must-not-appear");
expect(warn).toHaveBeenCalled();
});
```
Add `vi` to that test file's Vitest import.
- [ ] **Step 2: Run route tests and verify failures**
Run: `cd backend && npx vitest run test/routes-settings.test.ts test/routes-sql-meta.test.ts`
Expected: FAIL because validation currently compares only `id` and the failure path
does not log.
- [ ] **Step 3: Implement composite validation and warnings**
In `settings.ts`, replace the available model type and predicate:
```ts
let available: { provider: string; id: string }[] = [];
// existing try/catch remains
if (available.length > 0 && !available.some(
(candidate) => candidate.provider === b.provider && candidate.id === b.model,
)) {
return reply.code(400).send({
error: `Unknown model: ${b.provider ?? "unknown"}/${b.model}`,
});
}
```
In `meta.ts`, accept the request and log only a sanitized type:
```ts
app.get("/models", async (req) => {
const fn = deps.listModels ?? (async () => []);
try {
return { models: await fn() };
} catch (error) {
app.log.warn({
component: "pi-model-list",
errorType: error instanceof Error ? error.name : typeof error,
}, "Pi model listing failed");
return { models: [] as PiModel[] };
}
});
```
In `app.ts`, enable warning-level explicit logs without request logging and route
configuration warnings through the same logger:
```ts
const app = Fastify({ logger: { level: "warn" }, disableRequestLogging: true });
const listModels = deps?.listModels ?? createPiModelLister(config, {
warn: (detail) => app.log.warn(
{ component: "pi-model-list", detail },
"Pi enabled-model configuration warning",
),
});
```
- [ ] **Step 4: Run route tests and the backend type checker**
Run: `cd backend && npx vitest run test/routes-settings.test.ts test/routes-sql-meta.test.ts && npx tsc --noEmit -p .`
Expected: all route tests PASS and TypeScript exits 0.
- [ ] **Step 5: Commit API validation and logging**
```bash
git add backend/src/routes/settings.ts backend/src/routes/meta.ts backend/src/app.ts backend/test/routes-settings.test.ts backend/test/routes-sql-meta.test.ts
git commit -m "fix(backend): validate configured model provider pair"
```
---
### Task 4: `local-qwen` session credential policy
**Files:**
- Modify: `backend/src/pi/provider-credentials.ts`
- Modify: `backend/test/provider-credentials.test.ts`
- Modify: `backend/test/pi-process-manager.test.ts`
**Interfaces:**
- Consumes: selected provider `local-qwen` from application settings.
- Produces: a scrubbed Pi child environment with no generic hosted-provider credential requirement.
- [ ] **Step 1: Write failing direct and session-spawn tests**
Add to `provider-credentials.test.ts`:
```ts
test("local-qwen is an explicit local provider and needs no generic key", () => {
const env = buildPiChildEnv({
ambient: {
PI_PROVIDER_API_KEY: "must-not-leak",
OPENAI_API_KEY: "must-not-leak",
THT_MODEL_API_KEY_FILE: "/must/not/leak",
},
provider: "local-qwen",
});
expect(env).not.toHaveProperty("PI_PROVIDER_API_KEY");
expect(env).not.toHaveProperty("OPENAI_API_KEY");
expect(env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
});
```
Change the existing local session test in `pi-process-manager.test.ts` to run for both
providers:
```ts
test.each(["ollama", "local-qwen"])(
"local provider %s spawns without a model key and scrubs ambient credentials",
async (provider) => {
vi.stubEnv("PI_PROVIDER_API_KEY", "ambient-secret");
vi.stubEnv("THT_MODEL_API_KEY_FILE", "/ambient/secret-path");
vi.stubEnv("OPENAI_API_KEY", "unselected-provider-secret");
const calls: any[][] = [];
const child = recordingChild();
child.stderr.resume = () => {};
const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
spawnFn: (...args: any[]) => { calls.push(args); return child as any; },
});
try {
await mgr.spawnFor(`local-session-${provider}`, { provider });
expect(calls[0][2].env).not.toHaveProperty("PI_PROVIDER_API_KEY");
expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY_FILE");
expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY");
} finally {
mgr.teardown(`local-session-${provider}`);
vi.unstubAllEnvs();
}
},
);
```
- [ ] **Step 2: Run the focused tests and verify `local-qwen` is rejected**
Run: `cd backend && npx vitest run test/provider-credentials.test.ts test/pi-process-manager.test.ts`
Expected: the `local-qwen` cases FAIL with `model provider credential is unavailable`.
- [ ] **Step 3: Add only `local-qwen` to the explicit local-provider set**
```ts
const LOCAL_PROVIDERS = new Set([
"ollama", "lmstudio", "local", "aritmolab", "local-qwen", "faux",
]);
```
Do not relax the unknown-provider or compound-provider branches.
- [ ] **Step 4: Run credential and process-manager tests**
Run: `cd backend && npx vitest run test/provider-credentials.test.ts test/pi-process-manager.test.ts`
Expected: all tests PASS, including the existing unknown/compound-provider failures.
- [ ] **Step 5: Commit the provider policy change**
```bash
git add backend/src/pi/provider-credentials.ts backend/test/provider-credentials.test.ts backend/test/pi-process-manager.test.ts
git commit -m "fix(backend): allow configured local Qwen provider"
```
---
### Task 5: Frontend selector regression test
**Files:**
- Modify: `frontend/src/shell/AppShell.new-session.test.tsx`
**Interfaces:**
- Consumes: unchanged `GET /models` response with three `PiModel` objects.
- Produces: test proof that the selector displays all three and persists Qwen's provider/model pair.
- [ ] **Step 1: Add the three-model selector test**
Add `within` to the Testing Library import, then add:
```tsx
test("model selector shows the three Pi-enabled models and persists the selected provider", async () => {
let saved: unknown;
server.use(
http.get("http://localhost:8787/settings", () => HttpResponse.json({
workspace: "default", provider: "zai", model: "glm-5.2", thinking: "low",
})),
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [
{ provider: "zai", id: "glm-5.2", name: "GLM-5.2", reasoning: true },
{ provider: "deepseek", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", reasoning: true },
{ provider: "local-qwen", id: "qwen3.6-35b-a3b", name: "Qwen3.6 35B A3B Local", reasoning: false },
] })),
http.put("http://localhost:8787/settings", async ({ request }) => {
saved = await request.json();
return HttpResponse.json(saved);
}),
);
renderShell();
const select = await screen.findByRole("combobox", { name: "Model" });
expect(within(select).getAllByRole("option").map((option) => option.textContent)).toEqual([
"GLM-5.2", "DeepSeek V4 Flash", "Qwen3.6 35B A3B Local",
]);
await userEvent.selectOptions(select, "qwen3.6-35b-a3b");
await waitFor(() => expect(saved).toMatchObject({
provider: "local-qwen", model: "qwen3.6-35b-a3b",
}));
});
```
- [ ] **Step 2: Run the focused frontend test**
Run: `cd frontend && npx vitest run src/shell/AppShell.new-session.test.tsx`
Expected: PASS with the existing component.
- [ ] **Step 3: Run frontend type checking**
Run: `cd frontend && npx tsc -b`
Expected: TypeScript exits 0.
- [ ] **Step 4: Commit the regression coverage**
```bash
git add frontend/src/shell/AppShell.new-session.test.tsx
git commit -m "test(frontend): cover configured model selector"
```
---
### Task 6: Full verification, live Pi scope, Docker recreation, and state record
**Files:**
- Modify: `/home/chirone/thothii-data/pi-config/agent/settings.json`
- Modify: `PROJECT_STATE.md`
**Interfaces:**
- Consumes: the completed backend/frontend commits and mounted Pi profile.
- Produces: deployed healthy `core` container whose live `/models` response contains exactly the three approved models.
- [ ] **Step 1: Run the complete source gates from a clean task branch**
```bash
cd backend && npx vitest run && npx tsc --noEmit -p .
cd ../frontend && npx vitest run && npx tsc -b
cd .. && git diff --check && git status --short
```
Expected: both complete test suites PASS, both type checks exit 0, `git diff --check`
prints nothing, and status contains only intentional task files plus the pre-existing
untracked `.vite/` directory in the main workspace.
- [ ] **Step 2: Update the mounted Pi scope using the approved exact list**
Apply this JSON change to
`/home/chirone/thothii-data/pi-config/agent/settings.json` while preserving all other
keys and formatting:
```diff
"enabledModels": [
"deepseek/deepseek-v4-flash",
"zai/glm-5.2",
- "local-qwen/qwen3.6-35b-a3b",
- "zai/glm-5v-turbo"
+ "local-qwen/qwen3.6-35b-a3b"
]
```
Validate without displaying credentials:
```bash
docker compose exec -T core node -e '
const s=require("/home/thoth/.pi/agent/settings.json");
console.log(JSON.stringify({enabledModels:s.enabledModels}));
'
```
Expected: the printed array contains exactly DeepSeek Flash, GLM 5.2, and local Qwen;
GLM-5V is absent.
- [ ] **Step 3: Build and recreate only the impacted `core` service**
```bash
docker compose build core
docker compose up -d --no-deps --force-recreate --wait --wait-timeout 60 core
docker compose ps core
```
Expected: build exits 0 and `core` reaches `running (healthy)`.
- [ ] **Step 4: Verify the live API returns the exact composite IDs**
```bash
docker compose exec -T core node --input-type=module - <<'NODE'
const response = await fetch("http://127.0.0.1:8787/models");
const body = await response.json();
const ids = body.models.map((m) => `${m.provider}/${m.id}`);
const expected = [
"deepseek/deepseek-v4-flash",
"zai/glm-5.2",
"local-qwen/qwen3.6-35b-a3b",
];
if (response.status !== 200 || JSON.stringify(ids) !== JSON.stringify(expected)) {
throw new Error(`unexpected model list: ${JSON.stringify(ids)}`);
}
console.log(JSON.stringify({ status: response.status, ids }));
NODE
```
Expected: status 200 and exactly the three IDs in Pi `enabledModels` order.
- [ ] **Step 5: Smoke-test settings validation and restore the original setting**
First exercise the same process-manager path used by a session, stopping before any
prompt is sent:
```bash
docker compose exec -T core node --input-type=module - <<'NODE'
import { loadConfig } from "/app/backend/dist/config.js";
import { PiProcessManager } from "/app/backend/dist/pi/pi-process-manager.js";
const manager = new PiProcessManager(loadConfig(process.env));
for (const [provider, model] of [
["deepseek", "deepseek-v4-flash"],
["local-qwen", "qwen3.6-35b-a3b"],
]) {
const id = `model-smoke-${provider}`;
const runtime = manager.createFor(id, { provider });
try {
await manager.configure(runtime, { provider, model, thinking: "low" });
console.log(JSON.stringify({ provider, model, configured: true }));
} finally {
manager.teardown(id);
}
}
NODE
```
Expected: both models print `configured: true`; no session prompt or DWH operation is
started.
Then verify API validation while restoring the user's setting:
```bash
docker compose exec -T core node --input-type=module - <<'NODE'
const base = "http://127.0.0.1:8787";
const original = await (await fetch(`${base}/settings`)).json();
async function put(provider, model) {
return fetch(`${base}/settings`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...original, provider, model }),
});
}
try {
for (const [provider, model] of [
["deepseek", "deepseek-v4-flash"],
["local-qwen", "qwen3.6-35b-a3b"],
]) {
const response = await put(provider, model);
if (response.status !== 200) throw new Error(`${provider}/${model}: ${response.status}`);
}
const hidden = await put("zai", "glm-5v-turbo");
if (hidden.status !== 400) throw new Error(`hidden model accepted: ${hidden.status}`);
console.log(JSON.stringify({ deepseek: 200, localQwen: 200, hiddenGlm5v: 400 }));
} finally {
const restored = await fetch(`${base}/settings`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify(original),
});
if (restored.status !== 200) throw new Error(`settings restore failed: ${restored.status}`);
}
NODE
```
Expected: DeepSeek and Qwen return 200, GLM-5V returns 400, and the original settings
are restored even if a smoke assertion fails.
- [ ] **Step 6: Inspect health, sanitized logs, and runtime identity**
```bash
docker compose ps core
docker compose logs --since=10m core
docker image inspect thothii-core:local --format '{{.Id}} {{.Created}}'
docker inspect thothii-core-1 --format '{{.Image}} {{.State.Health.Status}} {{.State.StartedAt}}'
```
Expected: health is `healthy`; logs contain no credential value, uncaught model-list
error, or unexpected Pi crash; the running container image ID equals the rebuilt image.
- [ ] **Step 7: Update and commit `PROJECT_STATE.md`**
Record the verified backend/frontend test totals, type-check success, the three live
model IDs, removal of GLM-5V, deployment timestamp, rebuilt image ID, running container
image ID, and health result. Preserve the file's current snapshot format.
```bash
git add PROJECT_STATE.md
git commit -m "docs: record model selector deployment"
git status --short
```
Expected: the commit succeeds and the task branch is clean apart from the known
untracked `.vite/` directory if it is visible in that workspace.
---
## Final review checklist
- [ ] Every approved model appears once and in Pi configuration order.
- [ ] GLM-5V and other authenticated Pi models are absent from `/models`.
- [ ] A missing `PI_PROVIDER` does not prevent model enumeration.
- [ ] Enumeration does not inherit or inject generic/provider credentials.
- [ ] DeepSeek and Qwen settings updates succeed; the hidden model is rejected.
- [ ] `local-qwen` spawns without a hosted-provider key.
- [ ] Unknown and compound providers still fail closed.
- [ ] No credential values appear in logs or test output.
- [ ] Backend and frontend complete gates pass.
- [ ] The rebuilt `core` container is healthy and runs the new image.