feat: establish unified administration and model context baseline

This commit is contained in:
Codex
2026-09-12 18:03:15 +02:00
parent 840344706f
commit f52bf22e05
74 changed files with 2334 additions and 523 deletions
@@ -102,7 +102,7 @@ export function loadMetadataGenerationModels(options: {
...(apiKeyEnv ? { apiKeyEnv, apiKey } : {}),
}));
}
const result = new RestartLoadedMetadataGenerationModels(models, catalog.defaultMetadataGeneration);
const result = new RestartLoadedMetadataGenerationModels(models, models.size ? catalog.defaultInteraction : null);
const safe = result.catalog();
return {
catalog: () => ({
+14 -16
View File
@@ -47,9 +47,8 @@ const runtimeModelSchema = z.object({
}).strict();
const catalogSchema = z.object({
schemaVersion: z.literal(1),
defaultSession: canonicalId,
defaultMetadataGeneration: canonicalId.optional(),
schemaVersion: z.literal(2),
defaultInteraction: canonicalId,
embedding: z.object({ id: canonicalId, dimensions: z.number().int().positive() }).strict(),
models: z.array(runtimeModelSchema).max(64),
}).strict();
@@ -57,8 +56,7 @@ const catalogSchema = z.object({
export type RuntimeModel = z.infer<typeof runtimeModelSchema>;
export interface RuntimeModelCatalog {
readonly defaultSession: string | null;
readonly defaultMetadataGeneration: string | null;
readonly defaultInteraction: string | null;
readonly embedding: Readonly<{ id: string; dimensions: number }> | null;
sessionModels(): readonly RuntimeModel[];
metadataModels(): readonly RuntimeModel[];
@@ -66,19 +64,21 @@ export interface RuntimeModelCatalog {
}
class RestartLoadedRuntimeModelCatalog implements RuntimeModelCatalog {
readonly defaultSession: string | null;
readonly defaultMetadataGeneration: string | null;
readonly defaultInteraction: string | null;
readonly embedding: Readonly<{ id: string; dimensions: number }> | null;
readonly #sessions: readonly RuntimeModel[];
readonly #metadata: readonly RuntimeModel[];
readonly #sessionIds: ReadonlySet<string>;
constructor(catalog?: z.infer<typeof catalogSchema>) {
this.defaultSession = catalog?.defaultSession ?? null;
this.defaultMetadataGeneration = catalog?.defaultMetadataGeneration ?? null;
this.defaultInteraction = catalog?.defaultInteraction ?? null;
this.embedding = catalog ? Object.freeze({ ...catalog.embedding }) : null;
this.#sessions = Object.freeze((catalog?.models ?? []).filter((model) => model.session !== undefined));
this.#metadata = Object.freeze((catalog?.models ?? []).filter((model) => model.metadataGeneration !== undefined));
// One operational list. Session-only installations can still run Core, but once Admin
// LLM models are configured every selectable model must support both adapters.
const hasMetadata = catalog?.models.some((model) => model.metadataGeneration !== undefined);
this.#sessions = Object.freeze((catalog?.models ?? []).filter((model) =>
model.session !== undefined && (!hasMetadata || model.metadataGeneration !== undefined)));
this.#metadata = Object.freeze(this.#sessions.filter((model) => model.metadataGeneration !== undefined));
this.#sessionIds = new Set(this.#sessions.map((model) => model.id));
}
@@ -129,11 +129,9 @@ export function loadRuntimeModelCatalog(file?: string): RuntimeModelCatalog {
if (ids.size !== parsed.data.models.length) throw new Error("runtime model catalog contains duplicate models");
const sessions = parsed.data.models.filter((model) => model.session !== undefined).map((model) => model.id);
const metadata = parsed.data.models.filter((model) => model.metadataGeneration !== undefined).map((model) => model.id);
if (!sessions.includes(parsed.data.defaultSession)) throw new Error("runtime model catalog session default is invalid");
if ((metadata.length > 0) !== (parsed.data.defaultMetadataGeneration !== undefined)
|| (parsed.data.defaultMetadataGeneration !== undefined
&& !metadata.includes(parsed.data.defaultMetadataGeneration))) {
throw new Error("runtime model catalog metadata default is invalid");
if (!sessions.includes(parsed.data.defaultInteraction)
|| (metadata.length > 0 && !metadata.includes(parsed.data.defaultInteraction))) {
throw new Error("runtime model catalog interaction default is invalid");
}
return new RestartLoadedRuntimeModelCatalog(parsed.data);
}
+9
View File
@@ -11,6 +11,7 @@ import {
validateDeclarativePiConfig,
} from "./managed-config.js";
import type { RuntimeModelCatalog } from "../models/runtime-model-catalog.js";
import { secretValue } from "../config/secret-bundle.js";
export interface PiModel {
provider: string;
@@ -64,6 +65,14 @@ export function createPiModelLister(cfg: AppConfig, opts: Opts = {}): ListModels
}
const env = buildPiChildEnv({});
// Pi's availability enumeration also needs the catalog-owned credentials for built-in
// providers. It must keep working after their obsolete Pi auth entries are removed.
for (const model of opts.modelCatalog?.sessionModels() ?? []) {
const name = model.authentication.mode === "secret_env" ? model.authentication.apiKeyEnv : undefined;
if (!name) continue;
const value = secretValue(cfg, name);
if (value) env[name] = value;
}
delete env.THT_DATA_ROOT;
if (cfg.dataRoot !== undefined) env.THT_DATA_ROOT = cfg.dataRoot;
const child = spawnFn(cfg.piBin, ["--mode", "rpc"], { cwd: cfg.harnessDir, env });
+13 -2
View File
@@ -138,7 +138,7 @@ export interface PiRuntimeAgentSnapshot {
* Bind a session Pi process to the exact managed auth/model bytes validated at spawn time.
* Other agent resources remain live through symlinks, while session storage stays persistent.
*/
export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot {
export function createPiRuntimeAgentSnapshot(options: { excludeAuthProvider?: string } = {}): PiRuntimeAgentSnapshot {
const sourceAgentDir = configuredPiAgentDir();
const auth = readPiAgentFile(sourceAgentDir, "auth.json", true);
const models = readPiAgentFile(sourceAgentDir, "models.json", true);
@@ -165,7 +165,18 @@ export function createPiRuntimeAgentSnapshot(): PiRuntimeAgentSnapshot {
);
}
if (auth !== undefined) {
writeFileSync(join(snapshotDir, "auth.json"), auth, { flag: "wx", mode: 0o600 });
let effectiveAuth = auth;
if (options.excludeAuthProvider) {
const parsed = parsePiConfigJson(auth);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new PiManagedConfigError();
const provider = options.excludeAuthProvider.trim().toLowerCase();
effectiveAuth = JSON.stringify(Object.fromEntries(
Object.entries(parsed).filter(([key]) => key.trim().toLowerCase() !== provider),
));
}
// secret_env is authoritative for this provider. Keep the operator's auth file intact,
// but do not let an old Pi credential override the shared bundle inside this child.
writeFileSync(join(snapshotDir, "auth.json"), effectiveAuth, { flag: "wx", mode: 0o600 });
}
if (models !== undefined) {
writeFileSync(join(snapshotDir, "models.json"), models, { flag: "wx", mode: 0o600 });
+8 -8
View File
@@ -106,23 +106,23 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
});
const credentialStatus = deps.credentialStatus ?? ((provider: string | undefined) => {
try {
const model = deps.modelCatalog.defaultSession
? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultSession)
const model = deps.modelCatalog.defaultInteraction
? deps.modelCatalog.sessionModels().find((entry) => entry.id === deps.modelCatalog.defaultInteraction)
: undefined;
const credentialName = model?.authentication.mode === "secret_env"
? model.authentication.apiKeyEnv
: undefined;
const configuredApiKey = configuredPiProviderApiKey(
const configuredApiKey = credentialName ? `$${credentialName}` : configuredPiProviderApiKey(
readConfiguredPiAgentFile("models.json", true),
provider,
) ?? (credentialName ? `$${credentialName}` : undefined);
);
return piProviderCredentialStatus({
provider,
authProviders: loadPiAuthProviders(),
authProviders: credentialName ? new Set() : loadPiAuthProviders(),
resolveCredentialValue: () => credentialName
? secretValue(config, credentialName)
: config.modelCatalogFile ? undefined : secretValue(config, "THT_MODEL_API_KEY"),
credentialFile: config.modelApiKeyFile,
credentialFile: credentialName ? undefined : config.modelApiKeyFile,
configuredApiKey,
});
} catch {
@@ -152,8 +152,8 @@ export function createPiManagement(config: AppConfig, deps: PiManagementDeps): P
const installationConfig = (): PiInstallationConfig => {
const settings = readSettings();
const reasoning = config.defaults.thinking ?? settings.thinking;
const selected = deps.modelCatalog.defaultSession
? splitCanonicalModelId(deps.modelCatalog.defaultSession)
const selected = deps.modelCatalog.defaultInteraction
? splitCanonicalModelId(deps.modelCatalog.defaultInteraction)
: undefined;
return {
...(selected ? selected : {}),
+11 -13
View File
@@ -63,7 +63,7 @@ export class PiProcessManager {
) {
this.modelCatalog = opts?.modelCatalog ?? loadRuntimeModelCatalog(cfg.modelCatalogFile);
this.modelCatalogConfigured = cfg.modelCatalogFile !== undefined
|| this.modelCatalog.defaultSession !== null;
|| this.modelCatalog.defaultInteraction !== null;
this.loadAuthProviders = opts?.authProviders
?? ((agentDir) => loadPiAuthProviders({ agentDir }));
if (opts?.spawnFn) {
@@ -89,25 +89,23 @@ export class PiProcessManager {
// This is the final shared boundary for createFor(), spawnFor(), and resume(). Validate
// before auth-provider inspection, then make Pi consume the exact copied bytes rather than
// reopening mutable mounted auth/models files after this check.
const agent = createPiRuntimeAgentSnapshot();
const catalogModel = provider && model
? this.modelCatalog.sessionModels().find((entry) => entry.provider === provider && entry.model === model)
: undefined;
const credentialName = catalogModel?.authentication.mode === "secret_env"
? catalogModel.authentication.apiKeyEnv : undefined;
const agent = createPiRuntimeAgentSnapshot({ excludeAuthProvider: credentialName ? provider : undefined });
let child: ChildProcessWithoutNullStreams | undefined;
try {
const catalogModel = provider && model
? this.modelCatalog.sessionModels()
.find((entry) => entry.provider === provider && entry.model === model)
: undefined;
const credentialName = catalogModel?.authentication.mode === "secret_env"
? catalogModel.authentication.apiKeyEnv
: undefined;
const projectedApiKey = configuredPiProviderApiKey(agent.models, provider)
?? (credentialName ? `$${credentialName}` : undefined);
const projectedApiKey = credentialName ? `$${credentialName}`
: configuredPiProviderApiKey(agent.models, provider);
const env = buildPiChildEnv({
provider,
authProviders: this.loadAuthProviders(agent.agentDir),
authProviders: credentialName ? new Set() : this.loadAuthProviders(agent.agentDir),
credentialValue: credentialName
? secretValue(this.cfg, credentialName)
: this.modelCatalogConfigured ? undefined : secretValue(this.cfg, "THT_MODEL_API_KEY"),
credentialFile: this.cfg.modelApiKeyFile,
credentialFile: credentialName ? undefined : this.cfg.modelApiKeyFile,
configuredApiKey: projectedApiKey,
additions: { THT_SESSION: sessionId, THT_AUTHOR: author },
});
+6 -5
View File
@@ -70,28 +70,29 @@ export function createPiProviderSmoke(
try {
const canonicalProvider = canonicalPiProvider(provider);
if (!canonicalProvider || timeoutMs <= 0) throw providerFailure();
const configuredAuthProviders = authProviders();
const configuredAuthProviders = new Set(authProviders());
const configuredModels = options.readModelsStore
? options.readModelsStore()
: readConfiguredPiAgentFile("models.json", true);
const catalog = options.modelCatalog ?? loadRuntimeModelCatalog(config.modelCatalogFile);
const catalogConfigured = config.modelCatalogFile !== undefined
|| catalog.defaultSession !== null;
|| catalog.defaultInteraction !== null;
const catalogModel = catalog.sessionModels()
.find((entry) => entry.provider === canonicalProvider && entry.model === model);
const upstreamModel = catalogModel?.upstreamModel ?? model;
const credentialName = catalogModel?.authentication.mode === "secret_env"
? catalogModel.authentication.apiKeyEnv
: undefined;
const projectedApiKey = configuredPiProviderApiKey(configuredModels, canonicalProvider)
?? (credentialName ? `$${credentialName}` : undefined);
if (credentialName) configuredAuthProviders.delete(canonicalProvider);
const projectedApiKey = credentialName ? `$${credentialName}`
: configuredPiProviderApiKey(configuredModels, canonicalProvider);
const env = buildPiChildEnv({
provider: canonicalProvider,
authProviders: configuredAuthProviders,
credentialValue: credentialName
? secretValue(config, credentialName)
: catalogConfigured ? undefined : secretValue(config, "THT_MODEL_API_KEY"),
credentialFile: config.modelApiKeyFile,
credentialFile: credentialName ? undefined : config.modelApiKeyFile,
configuredApiKey: projectedApiKey,
});
clearPrincipalEnvironment(env);
+22 -13
View File
@@ -383,6 +383,10 @@ export function sessionRoutes(
question: string; name?: string; workspace?: string; workspaceId?: string;
provider?: string; model?: string; thinking?: string;
};
if ((b.provider === undefined) !== (b.model === undefined)
|| (b.provider !== undefined && (typeof b.provider !== "string" || typeof b.model !== "string" || !b.provider || !b.model))) {
return reply.code(400).send({ error: "provider and model must be supplied together" });
}
const principal = getPrincipal(req);
let s: Settings;
try { s = await d.getSettings(principal); } catch { return storageFailure(reply); }
@@ -446,11 +450,9 @@ export function sessionRoutes(
}
}
const requestedCanonical = b.provider && b.model ? `${b.provider}/${b.model}` : undefined;
let selectedCanonical = requestedCanonical ?? d.modelCatalog.defaultSession;
let modelWarning: string | undefined;
if (selectedCanonical && d.modelCatalog.defaultSession && !d.modelCatalog.hasSession(selectedCanonical)) {
selectedCanonical = d.modelCatalog.defaultSession;
modelWarning = `Configured model ${requestedCanonical ?? "selection"} is unavailable; using ${selectedCanonical}.`;
const selectedCanonical = requestedCanonical ?? d.modelCatalog.defaultInteraction;
if (selectedCanonical && d.modelCatalog.defaultInteraction && !d.modelCatalog.hasSession(selectedCanonical)) {
return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" });
}
const selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : undefined;
const provider = selected?.provider ?? b.provider;
@@ -554,7 +556,7 @@ export function sessionRoutes(
),
() => d.mgr.start(id, rt, runtimeOptions),
);
return { id, ...(modelWarning ? { warning: modelWarning } : {}) };
return { id };
} finally {
if (revisionLease && !manifestPersisted) {
await revisionLease.abort().catch((error: unknown) => {
@@ -680,10 +682,17 @@ export function sessionRoutes(
provider?: string; model?: string; thinking?: string;
workspace_id?: string; workspace_revision?: string;
};
const savedCanonical = saved.provider && saved.model ? `${saved.provider}/${saved.model}` : "";
if (d.modelCatalog.defaultSession && (!savedCanonical || !d.modelCatalog.hasSession(savedCanonical))) {
const requested = (req.body ?? {}) as { provider?: string; model?: string; thinking?: string };
if ((requested.provider === undefined) !== (requested.model === undefined)
|| (requested.provider !== undefined && (typeof requested.provider !== "string" || typeof requested.model !== "string" || !requested.provider || !requested.model))) {
return reply.code(400).send({ error: "provider and model must be supplied together" });
}
const selectedCanonical = requested.provider && requested.model
? `${requested.provider}/${requested.model}` : d.modelCatalog.defaultInteraction;
if (d.modelCatalog.defaultInteraction && (!selectedCanonical || !d.modelCatalog.hasSession(selectedCanonical))) {
return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" });
}
const selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : saved;
let workspaceConfigPath: string;
let workspaceDescriptor: WorkspaceDescriptor | undefined;
try {
@@ -716,7 +725,7 @@ export function sessionRoutes(
if (existing) {
const state = existing.bridge.turnState();
if (state === "running" || state === "waiting") {
return reply.code(200).send({ id, alreadyActive: true });
return reply.code(200).send({ id, alreadyActive: true, workspaceId: saved.workspace_id });
}
}
const ensure = await d.readiness.ensure(
@@ -727,8 +736,8 @@ export function sessionRoutes(
...(ensure.code ? { code: ensure.code } : {}),
});
const options = {
provider: saved?.provider,
model: saved?.model,
provider: selected.provider,
model: selected.model,
thinking: saved?.thinking ?? settings.thinking,
author: principal.displayName ?? principal.subject,
principal,
@@ -750,7 +759,7 @@ export function sessionRoutes(
if (current) {
const state = current.bridge.turnState();
if (state === "running" || state === "waiting") {
return reply.code(200).send({ id, alreadyActive: true });
return reply.code(200).send({ id, alreadyActive: true, workspaceId: saved.workspace_id });
}
}
@@ -791,7 +800,7 @@ export function sessionRoutes(
d.mgr.configure(rt, runtimeOptions), null,
() => d.mgr.start(id, rt, runtimeOptions),
);
return reply.code(200).send({ id, alreadyActive: false });
return reply.code(200).send({ id, alreadyActive: false, workspaceId: saved.workspace_id });
});
});
app.post("/sessions/:id/close", async (req, reply) => {
+2 -2
View File
@@ -16,8 +16,8 @@ export function effectiveSettings(
modelCatalog?: RuntimeModelCatalog,
): Settings {
const workspaces = listWorkspaces(cfg.harnessDir);
const selected = modelCatalog?.defaultSession
? splitCanonicalModelId(modelCatalog.defaultSession)
const selected = modelCatalog?.defaultInteraction
? splitCanonicalModelId(modelCatalog.defaultInteraction)
: undefined;
return {
workspace: stored.workspace ?? workspaces[0]?.name,
+33 -1
View File
@@ -56,7 +56,7 @@ test("catalog listing translates upstream Pi IDs back to canonical model keys",
session: { reasoning: true, contextWindow: 32768, maxTokens: 8192 },
};
const modelCatalog: RuntimeModelCatalog = {
defaultSession: model.id, defaultMetadataGeneration: null, embedding: null,
defaultInteraction: model.id, embedding: null,
sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id,
};
try {
@@ -75,6 +75,38 @@ test("catalog listing translates upstream Pi IDs back to canonical model keys",
}
});
test("catalog listing supplies the shared DeepSeek key without requiring Pi auth", async () => {
const script = scriptWith([{ provider: "deepseek", id: "deepseek-v4-pro", name: "DeepSeek V4 Pro" }]);
const secret = join(path.dirname(script), "thothii.secrets");
writeFileSync(secret, "DEEPSEEK_API_KEY=shared-key\nOPENAI_API_KEY=unrelated-key\n", { mode: 0o600 });
const model: RuntimeModel = {
id: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
label: "DeepSeek V4 Pro", upstreamModel: "deepseek-v4-pro",
authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" },
sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: false },
};
const modelCatalog: RuntimeModelCatalog = {
defaultInteraction: model.id, embedding: null,
sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id,
};
try {
const lister = createPiModelLister(loadConfig({ THT_SECRETS_FILE: secret }), {
...noManagedModels, modelCatalog, loadEnabledModels: enabled(model.id),
spawnFn: (_command, _args, options) => {
expect(options.env.DEEPSEEK_API_KEY).toBe("shared-key");
expect(options.env).not.toHaveProperty("OPENAI_API_KEY");
expect(options.env).not.toHaveProperty("THT_SECRETS_FILE");
return spawn("node", [FAKE, script], { env: options.env }) as any;
},
});
await expect(lister()).resolves.toEqual([{
provider: model.provider, id: model.model, name: model.label, reasoning: false,
}]);
} finally {
rmSync(path.dirname(script), { recursive: true, force: true });
}
});
test("createPiModelLister caches within ttl (spawns once for two calls)", async () => {
const script = scriptWith([{ provider: "zai", id: "glm-5.2", name: "GLM 5.2", reasoning: true }]);
try {
@@ -20,9 +20,8 @@ function runtimeCatalog(overrides: Record<string, unknown> = {}, secrets = "OPEN
const catalogFile = join(root, "catalog.json");
const secretsFile = join(root, "thothii.secrets");
const catalog = {
schemaVersion: 1,
defaultSession: "zai/glm-5.3",
defaultMetadataGeneration: "zai/glm-5.3",
schemaVersion: 2,
defaultInteraction: "zai/glm-5.3",
embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 },
models: [
{
@@ -55,8 +54,8 @@ test("loads session default and safe metadata choices from the normalized runtim
const runtime = loadRuntimeModelCatalog(catalogFile);
const metadata = loadMetadataGenerationModels({ catalogFile, secretsFile });
expect(runtime.defaultSession).toBe("zai/glm-5.3");
expect(runtime.hasSession("deepseek/deepseek-v4-pro")).toBe(true);
expect(runtime.defaultInteraction).toBe("zai/glm-5.3");
expect(runtime.hasSession("deepseek/deepseek-v4-pro")).toBe(false);
expect(metadata.catalog()).toEqual({
models: [{ id: "zai/glm-5.3", label: "GLM 5.3" }],
default: "zai/glm-5.3",
@@ -69,14 +68,36 @@ test("loads session default and safe metadata choices from the normalized runtim
expect(() => metadata.resolve("zai/missing")).toThrow(MetadataGenerationModelUnavailableError);
});
test("DeepSeek uses one identity and bundle credential for native Pi and LiteLLM", () => {
const models = ["deepseek-v4-pro", "deepseek-v4-flash"].map((model) => ({
id: `deepseek/${model}`, provider: "deepseek", model, label: model, upstreamModel: model,
authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" },
sessionAdapter: { mode: "pi_builtin" }, metadataAdapter: { litellmProvider: "deepseek" },
session: { reasoning: false }, metadataGeneration: { disableThinking: false },
}));
const files = runtimeCatalog({ defaultInteraction: models[0].id, models }, "DEEPSEEK_API_KEY=shared-key\n");
const runtime = loadRuntimeModelCatalog(files.catalogFile);
const metadata = loadMetadataGenerationModels(files);
expect(runtime.sessionModels().map((model) => model.id)).toEqual(metadata.catalog().models.map((model) => model.id));
expect(metadata.catalog().default).toBe(runtime.defaultInteraction);
for (const model of models) {
expect(metadata.resolve(model.id)).toMatchObject({
id: model.id, provider: "deepseek", model: model.model,
apiKeyEnv: "DEEPSEEK_API_KEY", apiKey: "shared-key",
});
}
expect(() => metadata.resolve("deepseek-metadata/deepseek-v4-pro"))
.toThrow(MetadataGenerationModelUnavailableError);
});
test("returns empty catalogs when no runtime projection is configured", () => {
expect(loadRuntimeModelCatalog().defaultSession).toBeNull();
expect(loadRuntimeModelCatalog().defaultInteraction).toBeNull();
expect(loadMetadataGenerationModels({}).catalog()).toEqual({ models: [], default: null });
});
test("rejects a drifted default and an unprotected projection", () => {
const drifted = runtimeCatalog({ defaultSession: "zai/missing" });
expect(() => loadRuntimeModelCatalog(drifted.catalogFile)).toThrow("session default is invalid");
const drifted = runtimeCatalog({ defaultInteraction: "zai/missing" });
expect(() => loadRuntimeModelCatalog(drifted.catalogFile)).toThrow("interaction default is invalid");
const unprotected = runtimeCatalog();
chmodSync(unprotected.catalogFile, 0o666);
@@ -85,7 +106,7 @@ test("rejects a drifted default and an unprotected projection", () => {
test("rejects authentication semantics that cannot come from the installation catalog", () => {
const invalid = runtimeCatalog({
defaultMetadataGeneration: undefined,
defaultInteraction: undefined,
models: [{
id: "zai/glm-5.3",
provider: "zai",
@@ -112,3 +133,13 @@ test("splits canonical session identities without provider aliases", () => {
expect(splitCanonicalModelId("zai/glm-5.3")).toEqual({ provider: "zai", model: "glm-5.3" });
expect(() => splitCanonicalModelId("glm-5.3")).toThrow("model identity is invalid");
});
test("rejects a default supported by only one configured use", () => {
const { catalogFile } = runtimeCatalog({ defaultInteraction: "deepseek/deepseek-v4-pro" });
expect(() => loadRuntimeModelCatalog(catalogFile)).toThrow("interaction default is invalid");
});
test("requires regenerated runtime schema v2 rather than interpreting two legacy defaults", () => {
const { catalogFile } = runtimeCatalog({ schemaVersion: 1, defaultSession: "zai/glm-5.3" });
expect(() => loadRuntimeModelCatalog(catalogFile)).toThrow("runtime model catalog is invalid");
});
+30 -4
View File
@@ -1,4 +1,4 @@
import { mkdtempSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test, vi } from "vitest";
@@ -7,7 +7,34 @@ import {
createPiManagement,
type PiExecFile,
} from "../src/pi/management.js";
import type { RuntimeModelCatalog } from "../src/models/runtime-model-catalog.js";
import type { RuntimeModel, RuntimeModelCatalog } from "../src/models/runtime-model-catalog.js";
test.each([false, true])("catalog credential status ignores legacy Pi auth, missing bundle key: %s", async (missingKey) => {
const root = mkdtempSync(join(tmpdir(), "tht-catalog-status-"));
writeFileSync(join(root, "auth.json"), JSON.stringify({ deepseek: { type: "api_key", key: "stale-key" } }), { mode: 0o600 });
const secret = join(root, "thothii.secrets");
writeFileSync(secret, missingKey ? "THT_MODEL_API_KEY=legacy-key\n" : "DEEPSEEK_API_KEY=shared-key\n", { mode: 0o600 });
vi.stubEnv("PI_CODING_AGENT_DIR", root);
const model: RuntimeModel = {
id: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
label: "DeepSeek", upstreamModel: "deepseek-v4-pro",
authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" },
sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: false },
};
try {
const service = createPiManagement(loadConfig({ THT_SECRETS_FILE: secret }), {
modelCatalog: {
defaultInteraction: model.id, embedding: null,
sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id,
},
execute: successfulExec([]), readSettings: () => ({ thinking: "medium" }),
});
expect((await service.status()).credentials).toBe(missingKey ? "missing" : "present");
} finally {
vi.unstubAllEnvs();
rmSync(root, { recursive: true, force: true });
}
});
function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-management-")), "settings.json")) {
return loadConfig({
@@ -19,8 +46,7 @@ function configFor(settingsFile = join(mkdtempSync(join(tmpdir(), "tht-pi-manage
}
const modelCatalog: RuntimeModelCatalog = {
defaultSession: "zai/glm-5.2",
defaultMetadataGeneration: null,
defaultInteraction: "zai/glm-5.2",
embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 },
sessionModels: () => [],
metadataModels: () => [],
+31 -16
View File
@@ -642,27 +642,33 @@ test("session Pi spawn reads the single secret bundle and scrubs its path", asyn
}
});
test("session Pi spawn resolves the selected catalog credential from the secret bundle", () => {
test.each([false, true])("session Pi uses the catalog bundle despite stale Pi auth: %s", (missingKey) => {
const root = mkdtempSync(path.join(tmpdir(), "thothii-catalog-credential-"));
const agentDir = path.join(root, "agent");
mkdirSync(agentDir, { mode: 0o700 });
writeFileSync(path.join(agentDir, "auth.json"), "{}\n", { mode: 0o600 });
const originalAuth = JSON.stringify({
deepseek: { type: "api_key", key: "stale-key" },
anthropic: { type: "api_key", key: "unrelated-key" },
});
writeFileSync(path.join(agentDir, "auth.json"), originalAuth, { mode: 0o600 });
writeFileSync(path.join(agentDir, "models.json"), '{"providers":{}}\n', { mode: 0o600 });
const secret = path.join(root, "thothii.secrets");
writeFileSync(secret, "ZAI_API_KEY=catalog-secret\nTHT_MODEL_API_KEY=legacy-secret\n", { mode: 0o600 });
writeFileSync(secret, missingKey ? "THT_MODEL_API_KEY=legacy-secret\n"
: "DEEPSEEK_API_KEY=catalog-secret\nTHT_MODEL_API_KEY=legacy-secret\n", { mode: 0o600 });
const legacyKey = path.join(root, "legacy-key");
writeFileSync(legacyKey, "legacy-file-key", { mode: 0o600 });
const model: RuntimeModel = {
id: "openai/test-model",
provider: "openai",
model: "test-model",
label: "Test model",
upstreamModel: "test-model",
authentication: { mode: "secret_env", apiKeyEnv: "ZAI_API_KEY" },
id: "deepseek/deepseek-v4-pro",
provider: "deepseek",
model: "deepseek-v4-pro",
label: "DeepSeek V4 Pro",
upstreamModel: "deepseek-v4-pro",
authentication: { mode: "secret_env", apiKeyEnv: "DEEPSEEK_API_KEY" },
sessionAdapter: { mode: "pi_builtin" },
session: { reasoning: false },
};
const modelCatalog: RuntimeModelCatalog = {
defaultSession: model.id,
defaultMetadataGeneration: null,
defaultInteraction: model.id,
embedding: null,
sessionModels: () => [model],
metadataModels: () => [],
@@ -672,17 +678,26 @@ test("session Pi spawn resolves the selected catalog credential from the secret
const child = recordingChild();
child.stderr.resume = () => {};
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
const mgr = new PiProcessManager(loadConfig({ THT_SECRETS_FILE: secret }), {
const mgr = new PiProcessManager(loadConfig({ THT_SECRETS_FILE: secret, THT_MODEL_API_KEY_FILE: legacyKey }), {
modelCatalog,
authProviders: () => new Set(),
authProviders: () => new Set(["deepseek"]),
spawnFn: (...args: any[]) => { calls.push(args); return child as any; },
});
try {
mgr.createFor("catalog-credential", { provider: "openai", model: "test-model" });
expect(calls[0][2].env.ZAI_API_KEY).toBe("catalog-secret");
const create = () => mgr.createFor("catalog-credential", { provider: model.provider, model: model.model });
if (missingKey) {
expect(create).toThrow("model provider credential is unavailable");
expect(calls).toHaveLength(0);
return;
}
create();
expect(calls[0][2].env.DEEPSEEK_API_KEY).toBe("catalog-secret");
expect(calls[0][2].env).not.toHaveProperty("OPENAI_API_KEY");
expect(calls[0][2].env).not.toHaveProperty("THT_MODEL_API_KEY");
expect(JSON.parse(readFileSync(path.join(calls[0][2].env.PI_CODING_AGENT_DIR, "auth.json"), "utf8")))
.toEqual({ anthropic: { type: "api_key", key: "unrelated-key" } });
} finally {
expect(readFileSync(path.join(agentDir, "auth.json"), "utf8")).toBe(originalAuth);
mgr.teardown("catalog-credential");
vi.unstubAllEnvs();
rmSync(root, { recursive: true, force: true });
@@ -750,7 +765,7 @@ test("set_model translates a canonical catalog key to its upstream Pi model ID",
session: { reasoning: false, contextWindow: 32768, maxTokens: 8192 },
};
const modelCatalog: RuntimeModelCatalog = {
defaultSession: model.id, defaultMetadataGeneration: null, embedding: null,
defaultInteraction: model.id, embedding: null,
sessionModels: () => [model], metadataModels: () => [], hasSession: (id) => id === model.id,
};
const mgr = new PiProcessManager(loadConfig({ PI_BIN: "/usr/local/bin/pi" }), {
+7 -4
View File
@@ -61,20 +61,22 @@ test("provider smoke resolves the selected catalog credential from the secret bu
session: { reasoning: false },
};
const modelCatalog: RuntimeModelCatalog = {
defaultSession: model.id,
defaultMetadataGeneration: null,
defaultInteraction: model.id,
embedding: null,
sessionModels: () => [model],
metadataModels: () => [],
hasSession: (id) => id === model.id,
};
let spawnEnv: NodeJS.ProcessEnv | undefined;
const readAuthStore = vi.fn(() => JSON.stringify({ openai: { type: "api_key", key: "stale-key" } }));
const smoke = createPiProviderSmoke(loadConfig({ THT_SECRETS_FILE: secret }), {
modelCatalog,
authProviders: () => new Set(),
authProviders: () => new Set(["openai"]),
readAuthStore,
readModelsStore: () => undefined,
spawnFn: (_command, _args, options) => {
spawnEnv = options.env;
expect(existsSync(join(options.env.PI_CODING_AGENT_DIR!, "auth.json"))).toBe(false);
return successfulProviderChild();
},
});
@@ -85,6 +87,7 @@ test("provider smoke resolves the selected catalog credential from the secret bu
expect(spawnEnv?.ZAI_API_KEY).toBe("catalog-secret");
expect(spawnEnv).not.toHaveProperty("OPENAI_API_KEY");
expect(spawnEnv).not.toHaveProperty("THT_MODEL_API_KEY");
expect(readAuthStore).not.toHaveBeenCalled();
} finally {
rmSync(root, { recursive: true, force: true });
}
@@ -306,7 +309,7 @@ test("provider smoke makes one configured request from an isolated no-capability
sessionAdapter: { mode: "pi_builtin" }, session: { reasoning: true },
};
const smokeCatalog: RuntimeModelCatalog = {
defaultSession: smokeModel.id, defaultMetadataGeneration: null, embedding: null,
defaultInteraction: smokeModel.id, embedding: null,
sessionModels: () => [smokeModel], metadataModels: () => [],
hasSession: (id) => id === smokeModel.id,
};
+31 -12
View File
@@ -27,10 +27,9 @@ function operationalWorkspace(id = "default") {
} as const;
}
function sessionCatalog(defaultSession = "zai/glm-5.2", available = [defaultSession]) {
function sessionCatalog(defaultInteraction = "zai/glm-5.2", available = [defaultInteraction]) {
return {
defaultSession,
defaultMetadataGeneration: null,
defaultInteraction,
embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 },
sessionModels: () => [],
metadataModels: () => [],
@@ -909,7 +908,7 @@ test("session lifecycle locates a B session when installation default is A", asy
active = undefined;
expect((await app.inject({ method: "POST", url: "/sessions/session-b/resume" })).json())
.toEqual({ id: "session-b", alreadyActive: false });
.toEqual({ id: "session-b", alreadyActive: false, workspaceId: "b-workspace" });
expect(calls).toContain(`new:${bPath}`);
expect(calls).toContain(`list:${bPath}`);
expect(calls).toContain(`show:${bPath}`);
@@ -1179,6 +1178,29 @@ test("POST /sessions/:id/resume uses the manifest's retained workspace revision"
);
});
test.each([undefined, { provider: "local", model: "qwen" }])("resume uses the global model/default, not the historical manifest (%j)", async (payload) => {
const configure = vi.fn(async () => {});
const manifest = { status: "open", archived: false, provider: "retired", model: "old-model" };
const runtime = { bridge: { onClientEvent: () => {}, emitClientEvent: () => {} } };
let current: any;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
mgr: {
get: () => current,
createFor: () => { current = runtime; return runtime; },
configure, start: () => {},
} as any,
thtRunner: { sessionShow: async () => manifest, reopenSession: async () => {} } as any,
readiness: { ensure: async () => ({ ok: true }) } as any,
runtimeModelCatalog: sessionCatalog("zai/glm-5.2", ["zai/glm-5.2", "local/qwen"]),
getSettings: () => ({ workspace: "psd", thinking: "medium" }) as any,
});
const response = await app.inject({ method: "POST", url: "/sessions/model-resume/resume", ...(payload ? { payload } : {}) });
expect(response.statusCode).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
expect(configure).toHaveBeenCalledWith(expect.anything(), expect.objectContaining(payload ?? { provider: "zai", model: "glm-5.2" }));
expect(manifest).toMatchObject({ provider: "retired", model: "old-model" });
});
test("POST /sessions/:id/resume returns a sanitized error when its retained revision is unavailable", async () => {
const rawFailure = "cannot read /data/workspace-registry/snapshots/secret-revision";
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
@@ -2691,7 +2713,7 @@ test("POST /sessions proceeds when ollamaEnsure succeeds", async () => {
expect(ensureWs).toContain(`/snapshots/${"e".repeat(40)}/psd.yaml`);
});
test("POST /sessions falls back from a stale requested model to the catalog default", async () => {
test("POST /sessions rejects a stale requested model without silently using the default", async () => {
let created = 0;
let persisted: any;
const runtime = { bridge: { onClientEvent: () => {} } };
@@ -2721,13 +2743,10 @@ test("POST /sessions falls back from a stale requested model to the catalog defa
payload: { question: "q", provider: "deepseek", model: "deepseek-v4-pro" },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
id: "fallback",
warning: "Configured model deepseek/deepseek-v4-pro is unavailable; using zai/glm-5.2.",
});
expect(persisted).toMatchObject({ provider: "zai", model: "glm-5.2" });
expect(created).toBe(1);
expect(res.statusCode).toBe(503);
expect(res.json()).toMatchObject({ code: "model_unavailable" });
expect(persisted).toBeUndefined();
expect(created).toBe(0);
});
test("POST /sessions marks a persisted session failed when runtime construction throws", async () => {
+2 -4
View File
@@ -160,8 +160,7 @@ test("GET /models returns session choices from the installation model catalog",
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {} as any,
runtimeModelCatalog: {
defaultSession: "zai/glm-5.2",
defaultMetadataGeneration: null,
defaultInteraction: "zai/glm-5.2",
embedding: { id: "ollama/qwen3-embedding:0.6b", dimensions: 1024 },
sessionModels: () => [{
id: "zai/glm-5.2", provider: "zai", model: "glm-5.2", label: "GLM 5.2",
@@ -187,8 +186,7 @@ test("GET /models returns an empty list when the catalog has no session models",
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {} as any,
runtimeModelCatalog: {
defaultSession: null,
defaultMetadataGeneration: null,
defaultInteraction: null,
embedding: null,
sessionModels: () => [],
metadataModels: () => [],