feat: unify installation model catalog
This commit is contained in:
@@ -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
@@ -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,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user