feat: unify installation model catalog

This commit is contained in:
Codex
2026-09-02 18:45:33 +02:00
parent ae053961a3
commit 7b7927bfe5
169 changed files with 3696 additions and 4572 deletions
@@ -32,7 +32,7 @@ import {
} from "../catalog/types.js";
const idSchema = z.uuid();
const modelIdSchema = z.string().regex(/^[a-z][a-z0-9._-]{0,63}$/);
const modelIdSchema = z.string().regex(/^[a-z][a-z0-9._-]{0,63}\/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/);
const selectedTargetIdsSchema = z.array(idSchema).min(1);
const suggestionSchema = z.discriminatedUnion("scope", [
z.object({ modelId: modelIdSchema, scope: z.literal("all") }).strict(),
+10 -15
View File
@@ -1,10 +1,8 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
import type { FastifyInstance } from "fastify";
import type { PiModel } from "../pi/list-models.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
export type ListModelsFn = () => Promise<PiModel[]>;
import type { RuntimeModelCatalog } from "../models/runtime-model-catalog.js";
/**
* List YAML workspace configs found in <harnessDir>/workspaces/*.yaml.
@@ -25,20 +23,17 @@ export function listWorkspaces(harnessDir: string): { name: string; file: string
export function metaRoutes(
app: FastifyInstance,
deps: { harnessDir: string; listModels?: ListModelsFn },
deps: { harnessDir: string; modelCatalog: RuntimeModelCatalog },
): void {
app.get("/models", async (request, reply) => {
if (!isPrincipalContext(requirePermission(request, reply, "session.use"))) return reply;
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");
// Graceful fallback: Pi may not be running; don't crash the server.
return { models: [] as PiModel[] };
}
return {
models: deps.modelCatalog.sessionModels().map((entry) => ({
provider: entry.provider,
id: entry.model,
name: entry.label,
reasoning: entry.session?.reasoning ?? false,
})),
};
});
}
+1 -9
View File
@@ -11,13 +11,6 @@ export function piManagementRoutes(
deps: { service: PiManagementService },
): void {
app.get("/pi-management/status", async (request, reply) => run(request, reply, deps, () => deps.service.status()));
app.get("/pi-management/options", async (request, reply) => run(request, reply, deps, () => deps.service.options()));
app.put("/pi-management/config", async (request, reply) => run(
request,
reply,
deps,
() => deps.service.configure((request.body ?? {}) as Record<string, unknown>),
));
app.post("/pi-management/test", async (request, reply) => run(request, reply, deps, () => deps.service.test()));
app.get("/pi-management/logs", async (request, reply) => run(request, reply, deps, () => deps.service.logs()));
}
@@ -38,8 +31,7 @@ async function run<T>(
return await action();
} catch (error) {
if (error instanceof PiManagementError) {
const statusCode = error.code === "pi_management_invalid_config" ? 400 : 503;
return reply.code(statusCode).send({ code: error.code, error: error.message });
return reply.code(503).send({ code: error.code, error: error.message });
}
return reply.code(503).send({ code: "pi_management_unavailable", error: "Pi management is unavailable" });
}
+18 -9
View File
@@ -6,12 +6,13 @@ import type { Settings } from "../settings/settings-store.js";
import { getPrincipal } from "../auth/auth.js";
import type { PrincipalContext } from "../auth/principal.js";
import type { ReadinessManager } from "../runtime/readiness-manager.js";
import type { ListModelsFn } from "./meta.js";
import type { ListModelsFn } from "../pi/list-models.js";
import type { WorkspaceRegistry } from "../workspaces/registry.js";
import { validateOperationalWorkspace, type WorkspaceDescriptor } from "../workspaces/schema.js";
import type { MaintenanceBarrier } from "../runtime/maintenance-gate.js";
import { hasPermission, isPrincipalContext, requirePermission } from "../auth/authorization.js";
import type { EffectiveRelationshipSnapshotProvider } from "../catalog/effective-relationship-snapshot.js";
import { splitCanonicalModelId, type RuntimeModelCatalog } from "../models/runtime-model-catalog.js";
const BOOTSTRAP_FAILURE_MESSAGE =
"Session startup failed. Check configuration and connectivity, then Resume the session.";
@@ -43,6 +44,7 @@ export function sessionRoutes(
maintenanceBarrier: MaintenanceBarrier;
/** Optional only for narrow route-test stubs and installations without a Catalog database. */
effectiveRelationships?: EffectiveRelationshipSnapshotProvider;
modelCatalog: RuntimeModelCatalog;
},
) {
const lifecycleTails = new Map<string, Promise<void>>();
@@ -358,7 +360,6 @@ export function sessionRoutes(
let workspaceId: string | undefined;
let workspaceRevision: string | undefined;
let workspaceDescriptor: WorkspaceDescriptor | undefined;
let allowedModels: readonly string[] | undefined;
if (requestedWorkspaceId) {
try {
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
@@ -378,7 +379,6 @@ export function sessionRoutes(
workspaceId = resolved.revision.id;
workspaceRevision = resolved.revision.commit;
workspaceDescriptor = resolved.workspace;
allowedModels = resolved.workspace.llm_policy.allowed;
} catch {
return reply.code(409).send({
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
@@ -386,12 +386,17 @@ export function sessionRoutes(
});
}
}
const provider = b.provider ?? s.provider;
const model = b.model ?? s.model;
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 selected = selectedCanonical ? splitCanonicalModelId(selectedCanonical) : undefined;
const provider = selected?.provider ?? b.provider;
const model = selected?.model ?? b.model;
const thinking = b.thinking ?? s.thinking;
if (allowedModels && provider && model && !allowedModels.includes(`${provider}/${model}`)) {
return reply.code(400).send({ error: "Selected model is not allowed by this workspace." });
}
// A persisted session is resumable without keeping Pi alive. New work replaces every
// runtime owned by this principal, while runtimes belonging to other users remain intact.
// Optional chaining preserves the deliberately narrow manager stubs used by route tests.
@@ -490,7 +495,7 @@ export function sessionRoutes(
),
() => d.mgr.start(id, rt, runtimeOptions),
);
return { id };
return { id, ...(modelWarning ? { warning: modelWarning } : {}) };
} finally {
if (revisionLease && !manifestPersisted) {
await revisionLease.abort().catch((error: unknown) => {
@@ -598,6 +603,10 @@ 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))) {
return reply.code(503).send({ error: MODEL_UNAVAILABLE_MESSAGE, code: "model_unavailable" });
}
let workspaceConfigPath: string;
let workspaceDescriptor: WorkspaceDescriptor | undefined;
try {
+16 -22
View File
@@ -1,17 +1,27 @@
import type { FastifyInstance } from "fastify";
import type { AppConfig } from "../config.js";
import type { Settings } from "../settings/settings-store.js";
import { listWorkspaces, type ListModelsFn } from "./meta.js";
import { listWorkspaces } from "./meta.js";
import type { PrincipalContext } from "../auth/principal.js";
import { isPrincipalContext, requirePermission } from "../auth/authorization.js";
import {
splitCanonicalModelId,
type RuntimeModelCatalog,
} from "../models/runtime-model-catalog.js";
/** Merge stored settings over env/first-workspace defaults. */
export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
/** Merge only workspace and runtime-thinking preferences; model defaults belong to modelCatalog. */
export function effectiveSettings(
cfg: AppConfig,
stored: Settings,
modelCatalog?: RuntimeModelCatalog,
): Settings {
const workspaces = listWorkspaces(cfg.harnessDir);
const selected = modelCatalog?.defaultSession
? splitCanonicalModelId(modelCatalog.defaultSession)
: undefined;
return {
workspace: stored.workspace ?? workspaces[0]?.name,
provider: cfg.defaults.provider ?? stored.provider,
model: cfg.defaults.model ?? stored.model,
...(selected ?? {}),
thinking: cfg.defaults.thinking ?? stored.thinking,
};
}
@@ -19,7 +29,7 @@ export function effectiveSettings(cfg: AppConfig, stored: Settings): Settings {
export function settingsRoutes(
app: FastifyInstance,
deps: {
cfg: AppConfig; listModels: ListModelsFn;
cfg: AppConfig;
getSettings: (principal: PrincipalContext) => Promise<Settings>;
},
): void {
@@ -37,22 +47,6 @@ export function settingsRoutes(
const principal = requirePermission(req, reply, "settings.manage");
if (!isPrincipalContext(principal)) return principal;
const b = (req.body ?? {}) as Settings;
if (b.model) {
let available: { provider: string; id: string }[] = [];
try {
available = await deps.listModels();
} catch {
available = [];
}
// Only validate when Pi gave us a non-empty list; otherwise allow (degraded).
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}`,
});
}
}
try {
// Retain this endpoint as a validating compatibility surface for older clients, but do
// not write anonymous users' choices to shared server storage.