forked from bai/curriculum-project-hub
35251986af
The admin role model picker was hardcoded to the env-default model registry (createDefaultModelRegistry), which only ever returned a single Sonnet model. Roles could not select any other model regardless of what the org's provider connection supported. Replace the env-only model list with a ProviderModelCatalog that: - Resolves the org's ACTIVE provider connection credential (BYOK or platform-managed, encrypted via ADR-0024 envelope) - Calls OpenRouter GET /v1/models?supported_parameters=tools to list tool-capable models available to that org - Caches results in-memory with a 5-minute TTL per organization - Falls back to the env-default registry when no ACTIVE provider exists The runtime modelRegistry no longer validates role.defaultModel against the env model list — the admin already validated by selection from the provider catalog. The env list remains as the fallback for roles with null defaultModel. The admin roles page loads models independently (non-blocking) so roles remain editable even if the provider API is slow or unreachable.
247 lines
10 KiB
TypeScript
247 lines
10 KiB
TypeScript
/**
|
|
* Org-admin Agent configuration routes (ADR-0017 / ADR-0018).
|
|
*
|
|
* Surface for browsing and editing Organization-scoped Agent roles, the skills
|
|
* bound to them, and the model picker. The model list is **derived** from the
|
|
* org's ACTIVE provider connection via OpenRouter's /v1/models API — there is
|
|
* no separate model table to maintain. When no ACTIVE provider exists, the
|
|
* route falls back to the env-default model registry so the admin surface
|
|
* remains usable.
|
|
*
|
|
* Skill management (create, read, edit, disable) is performed in-band through
|
|
* the deep module `OrganizationAgentConfiguration`, which writes to the same
|
|
* content-addressed persistent store used by the host-console CLI. All skill
|
|
* content flows through `importSkillFromFiles` → `inspectSkillDirectory` →
|
|
* `commitSkillContent`, so the web path and CLI path share one ingestion
|
|
* pipeline and one set of safety checks (SKILL.md manifest required, 512-file
|
|
* / 16-byte limits, symlink rejection).
|
|
*/
|
|
import type { PrismaClient } from "@prisma/client";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { OrganizationAgentConfiguration } from "../../agent/configuration.js";
|
|
import { ProviderModelCatalog } from "../../connections/providerModelCatalog.js";
|
|
import { createDefaultModelRegistry } from "../../settings/runtime.js";
|
|
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
|
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
|
import { handleRouteError } from "../errors.js";
|
|
|
|
export interface AgentConfigRouteConfig {
|
|
readonly prisma: PrismaClient;
|
|
readonly sessionSecret: string;
|
|
readonly skillStoreRoot: string;
|
|
readonly secretEnvelope: LocalSecretEnvelope;
|
|
}
|
|
|
|
export async function registerAgentConfigRoutes(
|
|
app: FastifyInstance,
|
|
config: AgentConfigRouteConfig,
|
|
): Promise<void> {
|
|
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
|
const agentConfig = new OrganizationAgentConfiguration(config.prisma, config.skillStoreRoot);
|
|
const modelCatalog = new ProviderModelCatalog(config.prisma, config.secretEnvelope);
|
|
|
|
app.get("/api/org/:orgSlug/agent-roles", async (request, reply) => {
|
|
try {
|
|
const { orgSlug } = request.params as { orgSlug: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const roles = await agentConfig.listRoles({ organizationId: auth.organization.id });
|
|
return { roles };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.put("/api/org/:orgSlug/agent-roles/:roleId", async (request, reply) => {
|
|
try {
|
|
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const body = request.body as {
|
|
label?: unknown;
|
|
defaultModel?: unknown;
|
|
systemPrompt?: unknown;
|
|
tools?: unknown;
|
|
sortOrder?: unknown;
|
|
isDefault?: unknown;
|
|
};
|
|
if (typeof body.label !== "string" || body.label.trim() === "") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "label is required" },
|
|
});
|
|
}
|
|
const role = await agentConfig.upsertRole({
|
|
organizationId: auth.organization.id,
|
|
roleId,
|
|
label: body.label,
|
|
...(body.defaultModel === null || typeof body.defaultModel === "string"
|
|
? { defaultModel: body.defaultModel as string | null }
|
|
: {}),
|
|
...(body.systemPrompt === null || typeof body.systemPrompt === "string"
|
|
? { systemPrompt: body.systemPrompt as string | null }
|
|
: {}),
|
|
...(body.tools === null || Array.isArray(body.tools)
|
|
? { tools: body.tools as readonly string[] | null }
|
|
: {}),
|
|
...(typeof body.sortOrder === "number" ? { sortOrder: body.sortOrder } : {}),
|
|
...(typeof body.isDefault === "boolean" ? { isDefault: body.isDefault } : {}),
|
|
});
|
|
return role;
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.put("/api/org/:orgSlug/agent-roles/:roleId/skills", async (request, reply) => {
|
|
try {
|
|
const { orgSlug, roleId } = request.params as { orgSlug: string; roleId: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const body = request.body as { skillNames?: unknown };
|
|
if (!Array.isArray(body.skillNames) || body.skillNames.some((n) => typeof n !== "string")) {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "skillNames must be a string array" },
|
|
});
|
|
}
|
|
await agentConfig.setRoleSkills({
|
|
organizationId: auth.organization.id,
|
|
roleId,
|
|
skillNames: body.skillNames as readonly string[],
|
|
});
|
|
return { skillNames: body.skillNames as string[] };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get("/api/org/:orgSlug/agent-skills", async (request, reply) => {
|
|
try {
|
|
const { orgSlug } = request.params as { orgSlug: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
|
|
return { skills };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get("/api/org/:orgSlug/agent-skills/:name/files", async (request, reply) => {
|
|
try {
|
|
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const skills = await agentConfig.listSkills({ organizationId: auth.organization.id });
|
|
const skill = skills.find((s) => s.name === name && s.disabledAt === null);
|
|
if (skill === undefined) {
|
|
return reply.status(404).send({
|
|
error: { code: "not_found", message: `skill not found: ${name}` },
|
|
});
|
|
}
|
|
const files = await agentConfig.readSkillFiles({
|
|
organizationId: auth.organization.id,
|
|
contentDigest: skill.contentDigest,
|
|
});
|
|
return { files };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.put("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
|
|
try {
|
|
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const body = request.body as { version?: unknown; files?: unknown };
|
|
if (typeof body.version !== "string" || body.version.trim() === "") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "version is required" },
|
|
});
|
|
}
|
|
if (!Array.isArray(body.files) || body.files.length === 0) {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "files must be a non-empty array" },
|
|
});
|
|
}
|
|
const skillFiles: Array<{ readonly path: string; readonly bytes: Buffer }> = [];
|
|
for (const entry of body.files) {
|
|
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "each file must be an object" },
|
|
});
|
|
}
|
|
const e = entry as { path?: unknown; content?: unknown };
|
|
if (typeof e.path !== "string" || typeof e.content !== "string") {
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "each file needs string path and content" },
|
|
});
|
|
}
|
|
skillFiles.push({ path: e.path, bytes: Buffer.from(e.content, "utf8") });
|
|
}
|
|
const result = await agentConfig.installSkillFromFiles({
|
|
organizationId: auth.organization.id,
|
|
files: skillFiles,
|
|
version: body.version,
|
|
});
|
|
// The manifest name is authoritative — reject if it doesn't match the
|
|
// URL param, so clients can't silently rename a skill under a different
|
|
// route key.
|
|
if (result.name !== name) {
|
|
return reply.status(400).send({
|
|
error: {
|
|
code: "bad_request",
|
|
message: `skill manifest name "${result.name}" does not match route "${name}"`,
|
|
},
|
|
});
|
|
}
|
|
return { id: result.id, name: result.name, contentDigest: result.contentDigest };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.patch("/api/org/:orgSlug/agent-skills/:name", async (request, reply) => {
|
|
try {
|
|
const { orgSlug, name } = request.params as { orgSlug: string; name: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const body = request.body as { description?: unknown; disabled?: unknown };
|
|
if (body.disabled === true) {
|
|
await agentConfig.disableSkill({ organizationId: auth.organization.id, name });
|
|
return { disabled: true };
|
|
}
|
|
if (typeof body.description === "string") {
|
|
await agentConfig.updateSkillDescription({
|
|
organizationId: auth.organization.id,
|
|
name,
|
|
description: body.description,
|
|
});
|
|
return { updated: true };
|
|
}
|
|
return reply.status(400).send({
|
|
error: { code: "bad_request", message: "provide description or disabled: true" },
|
|
});
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
|
|
app.get("/api/org/:orgSlug/agent-models", async (request, reply) => {
|
|
try {
|
|
const { orgSlug } = request.params as { orgSlug: string };
|
|
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
if (auth === null) return;
|
|
const providerModels = await modelCatalog.listModels(auth.organization.id);
|
|
// Fall back to env-default registry when the org has no ACTIVE provider
|
|
// (or the provider API is unreachable on first load).
|
|
const models = providerModels.length > 0
|
|
? providerModels
|
|
: createDefaultModelRegistry(process.env).listModels();
|
|
return { models };
|
|
} catch (err) {
|
|
return handleRouteError(reply, err);
|
|
}
|
|
});
|
|
}
|