forked from bai/curriculum-project-hub
chore: drop superseded admin-panel agent-config prototype after rebase onto main
main now ships the canonical org-scoped agent-config implementation (OrganizationAgentRole/OrganizationAgentSkill + hub/src/agent/configuration.ts + hub/src/agent/skillStore.ts per ADR-0018, and envelope-encrypted OrganizationProviderConnection per ADR-0024). The earlier admin-panel prototype (OrgModel/OrgRole/simple ProviderConnection baseUrl+authToken, modelRoutes.ts, agentConfig.ts, migration 20260710120000, admin-web models/roles pages) is superseded and clashes with main's schema; drop it. Follow-up still needed: rewire admin-web SPA to the new config APIs (+layout.svelte nav still lists models/roles, RoleCard.svelte unused).
This commit is contained in:
@@ -1,169 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { api, type OrgModelRow } from '$lib/api';
|
|
||||||
import { fmtDateOnly } from '$lib/format';
|
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
||||||
import CheckboxControl from '$lib/components/CheckboxControl.svelte';
|
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
|
||||||
|
|
||||||
let models = $state<OrgModelRow[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
|
|
||||||
let newModelId = $state('');
|
|
||||||
let newLabel = $state('');
|
|
||||||
let newSortKey = $state('');
|
|
||||||
let adding = $state(false);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
try {
|
|
||||||
const res = await api.models(slug);
|
|
||||||
models = res.models;
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function add() {
|
|
||||||
if (!newModelId.trim() || !newLabel.trim()) return;
|
|
||||||
adding = true;
|
|
||||||
try {
|
|
||||||
const m = await api.createModel(slug, {
|
|
||||||
modelId: newModelId.trim(),
|
|
||||||
label: newLabel.trim(),
|
|
||||||
...(newSortKey.trim() ? { sortKey: newSortKey.trim() } : {})
|
|
||||||
});
|
|
||||||
models = [...models, m];
|
|
||||||
newModelId = '';
|
|
||||||
newLabel = '';
|
|
||||||
newSortKey = '';
|
|
||||||
toastSuccess('模型已添加');
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
adding = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(m: OrgModelRow, patch: Partial<OrgModelRow>) {
|
|
||||||
try {
|
|
||||||
const updated = await api.updateModel(slug, m.id, patch);
|
|
||||||
models = models.map((x) => (x.id === m.id ? updated : x));
|
|
||||||
toastSuccess('已保存');
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err instanceof Error ? err.message : String(err));
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(m: OrgModelRow) {
|
|
||||||
if (!confirm(`删除模型 ${m.label}?`)) return;
|
|
||||||
try {
|
|
||||||
await api.deleteModel(slug, m.id);
|
|
||||||
models = models.filter((x) => x.id !== m.id);
|
|
||||||
toastSuccess('模型已删除');
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (slug) load();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<PageHeader
|
|
||||||
title="模型"
|
|
||||||
description="本组织启用的模型清单。模型 ID 面向供应方接口;显示名面向教师侧切换。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<LoadingState />
|
|
||||||
{:else if error}
|
|
||||||
<ErrorBanner message={error} onretry={load} />
|
|
||||||
{:else}
|
|
||||||
<div class="saas-card-pad mb-6">
|
|
||||||
<h2 class="saas-section-title mb-4">添加模型</h2>
|
|
||||||
<div class="grid gap-3 md:grid-cols-[1.4fr_1fr_auto_auto]">
|
|
||||||
<input
|
|
||||||
class="saas-input font-mono text-sm"
|
|
||||||
placeholder="模型 ID(如 anthropic/claude-sonnet-5)"
|
|
||||||
bind:value={newModelId}
|
|
||||||
/>
|
|
||||||
<input class="saas-input" placeholder="显示名(如 Claude Sonnet 5)" bind:value={newLabel} />
|
|
||||||
<input class="saas-input w-28" placeholder="排序" bind:value={newSortKey} />
|
|
||||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>添加</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="saas-card overflow-hidden">
|
|
||||||
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
|
|
||||||
<h2 class="text-sm font-semibold">已启用模型</h2>
|
|
||||||
<span class="saas-badge-neutral">{models.length}</span>
|
|
||||||
</div>
|
|
||||||
{#if models.length === 0}
|
|
||||||
<EmptyState title="暂无已启用模型" description="空清单时会回退到环境默认模型。" />
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="data-table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>显示名</th>
|
|
||||||
<th>模型 ID</th>
|
|
||||||
<th>工具能力</th>
|
|
||||||
<th>排序</th>
|
|
||||||
<th>创建</th>
|
|
||||||
<th></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each models as m (m.id)}
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
class="saas-input py-1.5"
|
|
||||||
value={m.label}
|
|
||||||
onchange={(e) => save(m, { label: (e.target as HTMLInputElement).value })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
class="saas-input py-1.5 font-mono text-xs"
|
|
||||||
value={m.modelId}
|
|
||||||
onchange={(e) => save(m, { modelId: (e.target as HTMLInputElement).value })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<CheckboxControl
|
|
||||||
checked={m.toolCapable}
|
|
||||||
onchange={(toolCapable) => save(m, { toolCapable })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
class="saas-input w-20 py-1.5"
|
|
||||||
value={m.sortKey}
|
|
||||||
onchange={(e) => save(m, { sortKey: (e.target as HTMLInputElement).value })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td class="text-xs text-surface-600">{fmtDateOnly(m.createdAt)}</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<button class="saas-btn-danger !py-1 text-xs" onclick={() => remove(m)}>删除</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/state';
|
|
||||||
import { api, type OrgRoleRow, type OrgModelRow } from '$lib/api';
|
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
||||||
import RoleCard from '$lib/components/RoleCard.svelte';
|
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
|
||||||
|
|
||||||
let roles = $state<OrgRoleRow[]>([]);
|
|
||||||
let models = $state<OrgModelRow[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state<string | null>(null);
|
|
||||||
|
|
||||||
let newRoleId = $state('');
|
|
||||||
let newLabel = $state('');
|
|
||||||
let adding = $state(false);
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
error = null;
|
|
||||||
try {
|
|
||||||
const [r, m] = await Promise.all([api.roles(slug), api.models(slug)]);
|
|
||||||
roles = r.roles;
|
|
||||||
models = m.models;
|
|
||||||
} catch (err) {
|
|
||||||
error = err instanceof Error ? err.message : String(err);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function add() {
|
|
||||||
if (!newRoleId.trim() || !newLabel.trim()) return;
|
|
||||||
adding = true;
|
|
||||||
try {
|
|
||||||
const r = await api.createRole(slug, { roleId: newRoleId.trim(), label: newLabel.trim() });
|
|
||||||
roles = [...roles, r];
|
|
||||||
newRoleId = '';
|
|
||||||
newLabel = '';
|
|
||||||
toastSuccess('角色已创建');
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err instanceof Error ? err.message : String(err));
|
|
||||||
} finally {
|
|
||||||
adding = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(r: OrgRoleRow) {
|
|
||||||
if (!confirm(`删除角色 ${r.label} (${r.roleId})?`)) return;
|
|
||||||
try {
|
|
||||||
await api.deleteRole(slug, r.id);
|
|
||||||
roles = roles.filter((x) => x.id !== r.id);
|
|
||||||
toastSuccess('角色已删除');
|
|
||||||
} catch (err) {
|
|
||||||
toastError(err instanceof Error ? err.message : String(err));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (slug) load();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<PageHeader
|
|
||||||
title="角色"
|
|
||||||
description="角色按数据配置。角色 ID 同时是斜杠命令(如 /draft),可绑定默认模型、工具白名单与系统提示词。"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<LoadingState />
|
|
||||||
{:else if error}
|
|
||||||
<ErrorBanner message={error} onretry={load} />
|
|
||||||
{:else}
|
|
||||||
<div class="saas-card-pad mb-6">
|
|
||||||
<h2 class="saas-section-title mb-4">新建角色</h2>
|
|
||||||
<div class="grid gap-3 sm:grid-cols-[10rem_1fr_auto]">
|
|
||||||
<input class="saas-input font-mono text-sm" placeholder="角色 ID(如 draft)" bind:value={newRoleId} />
|
|
||||||
<input class="saas-input" placeholder="显示名(如 草稿)" bind:value={newLabel} />
|
|
||||||
<button class="saas-btn-primary" onclick={add} disabled={adding}>新建</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if roles.length === 0}
|
|
||||||
<div class="saas-card">
|
|
||||||
<EmptyState title="暂无角色" description="未配置时回退环境默认角色(draft / review)。" />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-4">
|
|
||||||
{#each roles as r (r.id)}
|
|
||||||
<RoleCard
|
|
||||||
{r}
|
|
||||||
{models}
|
|
||||||
{slug}
|
|
||||||
onremoved={() => remove(r)}
|
|
||||||
onupdated={(updated: OrgRoleRow) => {
|
|
||||||
roles = roles.map((x) => (x.id === r.id ? updated : x));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
-- ADR-0021 / ADR-0017: org-scoped model provider connection, enabled models,
|
|
||||||
-- and role presets. Aligns to Spec.System.Organization (ProviderCredentialMode,
|
|
||||||
-- OrganizationProviderConnection) and the "roles are data" seam in agent/models.
|
|
||||||
|
|
||||||
CREATE TYPE "ProviderCredentialMode" AS ENUM ('BYOK', 'PLATFORM_MANAGED');
|
|
||||||
|
|
||||||
CREATE TABLE "OrganizationProviderConnection" (
|
|
||||||
"organizationId" TEXT NOT NULL,
|
|
||||||
"providerId" TEXT NOT NULL DEFAULT 'openrouter',
|
|
||||||
"mode" "ProviderCredentialMode" NOT NULL DEFAULT 'PLATFORM_MANAGED',
|
|
||||||
"baseUrl" TEXT,
|
|
||||||
"authToken" TEXT,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "OrganizationProviderConnection_pkey" PRIMARY KEY ("organizationId")
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE "OrganizationProviderConnection"
|
|
||||||
ADD CONSTRAINT "OrganizationProviderConnection_organizationId_fkey"
|
|
||||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
CREATE TABLE "OrgModel" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"organizationId" TEXT NOT NULL,
|
|
||||||
"modelId" TEXT NOT NULL,
|
|
||||||
"label" TEXT NOT NULL,
|
|
||||||
"toolCapable" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
"sortKey" TEXT NOT NULL DEFAULT '',
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "OrgModel_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "OrgModel_organizationId_modelId_key" ON "OrgModel"("organizationId", "modelId");
|
|
||||||
CREATE INDEX "OrgModel_organizationId_sortKey_idx" ON "OrgModel"("organizationId", "sortKey");
|
|
||||||
|
|
||||||
ALTER TABLE "OrgModel"
|
|
||||||
ADD CONSTRAINT "OrgModel_organizationId_fkey"
|
|
||||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
CREATE TABLE "OrgRole" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"organizationId" TEXT NOT NULL,
|
|
||||||
"roleId" TEXT NOT NULL,
|
|
||||||
"label" TEXT NOT NULL,
|
|
||||||
"defaultModelId" TEXT,
|
|
||||||
"systemPrompt" TEXT,
|
|
||||||
"tools" JSONB,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "OrgRole_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX "OrgRole_organizationId_roleId_key" ON "OrgRole"("organizationId", "roleId");
|
|
||||||
CREATE INDEX "OrgRole_organizationId_idx" ON "OrgRole"("organizationId");
|
|
||||||
|
|
||||||
ALTER TABLE "OrgRole"
|
|
||||||
ADD CONSTRAINT "OrgRole_organizationId_fkey"
|
|
||||||
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
/**
|
|
||||||
* Org-admin routes for agent config: provider connection, enabled models, and
|
|
||||||
* role presets (ADR-0017 / ADR-0021). Mounted under `/api/org/:orgSlug`.
|
|
||||||
*/
|
|
||||||
import type { PrismaClient } from "@prisma/client";
|
|
||||||
import type { FastifyInstance } from "fastify";
|
|
||||||
import {
|
|
||||||
createOrgModel,
|
|
||||||
createOrgRole,
|
|
||||||
deleteOrgModel,
|
|
||||||
deleteOrgRole,
|
|
||||||
getProviderConnection,
|
|
||||||
listOrgModels,
|
|
||||||
listOrgRoles,
|
|
||||||
updateOrgModel,
|
|
||||||
updateOrgRole,
|
|
||||||
upsertProviderConnection,
|
|
||||||
} from "../../org/agentConfig.js";
|
|
||||||
import { requireOrgRole, type GuardDeps } from "../auth/guards.js";
|
|
||||||
import { handleRouteError } from "../errors.js";
|
|
||||||
|
|
||||||
const PROVIDER_MODES = ["BYOK", "PLATFORM_MANAGED"] as const;
|
|
||||||
type ProviderMode = (typeof PROVIDER_MODES)[number];
|
|
||||||
|
|
||||||
export async function registerModelRoutes(
|
|
||||||
app: FastifyInstance,
|
|
||||||
config: { readonly prisma: PrismaClient; readonly sessionSecret: string },
|
|
||||||
): Promise<void> {
|
|
||||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
|
||||||
|
|
||||||
// --- Provider connection ---------------------------------------------
|
|
||||||
|
|
||||||
app.get("/api/org/:orgSlug/provider-connection", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const { orgSlug } = request.params as { orgSlug: string };
|
|
||||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
||||||
if (auth === null) return;
|
|
||||||
return await getProviderConnection(config.prisma, auth.organization.id);
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.put("/api/org/:orgSlug/provider-connection", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const { orgSlug } = request.params as { orgSlug: string };
|
|
||||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
||||||
if (auth === null) return;
|
|
||||||
const body = request.body as {
|
|
||||||
providerId?: unknown;
|
|
||||||
mode?: unknown;
|
|
||||||
baseUrl?: unknown;
|
|
||||||
authToken?: unknown;
|
|
||||||
};
|
|
||||||
const mode = parseProviderMode(body.mode);
|
|
||||||
if (mode === null) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: { code: "bad_request", message: "mode must be BYOK or PLATFORM_MANAGED" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const connection = await upsertProviderConnection(config.prisma, {
|
|
||||||
organizationId: auth.organization.id,
|
|
||||||
mode,
|
|
||||||
...(typeof body.providerId === "string" ? { providerId: body.providerId } : {}),
|
|
||||||
...(body.baseUrl === null || typeof body.baseUrl === "string"
|
|
||||||
? { baseUrl: body.baseUrl as string | null }
|
|
||||||
: {}),
|
|
||||||
...(body.authToken === null || typeof body.authToken === "string"
|
|
||||||
? { authToken: body.authToken as string | null }
|
|
||||||
: {}),
|
|
||||||
});
|
|
||||||
return connection;
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Models ----------------------------------------------------------
|
|
||||||
|
|
||||||
app.get("/api/org/:orgSlug/models", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const { orgSlug } = request.params as { orgSlug: string };
|
|
||||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
||||||
if (auth === null) return;
|
|
||||||
return { models: await listOrgModels(config.prisma, auth.organization.id) };
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/org/:orgSlug/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 body = request.body as {
|
|
||||||
modelId?: unknown;
|
|
||||||
label?: unknown;
|
|
||||||
toolCapable?: unknown;
|
|
||||||
sortKey?: unknown;
|
|
||||||
};
|
|
||||||
if (typeof body.modelId !== "string" || typeof body.label !== "string") {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: { code: "bad_request", message: "modelId and label are required" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const model = await createOrgModel(config.prisma, {
|
|
||||||
organizationId: auth.organization.id,
|
|
||||||
modelId: body.modelId,
|
|
||||||
label: body.label,
|
|
||||||
...(typeof body.toolCapable === "boolean" ? { toolCapable: body.toolCapable } : {}),
|
|
||||||
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
|
|
||||||
});
|
|
||||||
return reply.status(201).send(model);
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.patch("/api/org/:orgSlug/models/:modelId", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const { orgSlug, modelId } = request.params as { orgSlug: string; modelId: string };
|
|
||||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
||||||
if (auth === null) return;
|
|
||||||
const body = request.body as {
|
|
||||||
label?: unknown;
|
|
||||||
toolCapable?: unknown;
|
|
||||||
sortKey?: unknown;
|
|
||||||
modelId?: unknown;
|
|
||||||
};
|
|
||||||
return await updateOrgModel(config.prisma, {
|
|
||||||
organizationId: auth.organization.id,
|
|
||||||
id: modelId,
|
|
||||||
...(typeof body.label === "string" ? { label: body.label } : {}),
|
|
||||||
...(typeof body.toolCapable === "boolean" ? { toolCapable: body.toolCapable } : {}),
|
|
||||||
...(typeof body.sortKey === "string" ? { sortKey: body.sortKey } : {}),
|
|
||||||
...(typeof body.modelId === "string" ? { modelId: body.modelId } : {}),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete("/api/org/:orgSlug/models/:modelId", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const { orgSlug, modelId } = request.params as { orgSlug: string; modelId: string };
|
|
||||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
||||||
if (auth === null) return;
|
|
||||||
return await deleteOrgModel(config.prisma, {
|
|
||||||
organizationId: auth.organization.id,
|
|
||||||
id: modelId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Roles -----------------------------------------------------------
|
|
||||||
|
|
||||||
app.get("/api/org/:orgSlug/roles", async (request, reply) => {
|
|
||||||
try {
|
|
||||||
const { orgSlug } = request.params as { orgSlug: string };
|
|
||||||
const auth = await requireOrgRole(request, reply, guardDeps, { orgSlug });
|
|
||||||
if (auth === null) return;
|
|
||||||
return { roles: await listOrgRoles(config.prisma, auth.organization.id) };
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/org/:orgSlug/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 body = request.body as {
|
|
||||||
roleId?: unknown;
|
|
||||||
label?: unknown;
|
|
||||||
defaultModelId?: unknown;
|
|
||||||
systemPrompt?: unknown;
|
|
||||||
tools?: unknown;
|
|
||||||
};
|
|
||||||
if (typeof body.roleId !== "string" || typeof body.label !== "string") {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: { code: "bad_request", message: "roleId and label are required" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const role = await createOrgRole(config.prisma, {
|
|
||||||
organizationId: auth.organization.id,
|
|
||||||
roleId: body.roleId,
|
|
||||||
label: body.label,
|
|
||||||
...(typeof body.defaultModelId === "string" ? { defaultModelId: body.defaultModelId } : {}),
|
|
||||||
...(body.defaultModelId === null ? { defaultModelId: null } : {}),
|
|
||||||
...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
|
|
||||||
...(body.systemPrompt === null ? { systemPrompt: null } : {}),
|
|
||||||
...(Array.isArray(body.tools) ? { tools: body.tools as readonly string[] } : {}),
|
|
||||||
...(body.tools === null ? { tools: null } : {}),
|
|
||||||
});
|
|
||||||
return reply.status(201).send(role);
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.patch("/api/org/:orgSlug/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;
|
|
||||||
defaultModelId?: unknown;
|
|
||||||
systemPrompt?: unknown;
|
|
||||||
tools?: unknown;
|
|
||||||
roleId?: unknown;
|
|
||||||
};
|
|
||||||
return await updateOrgRole(config.prisma, {
|
|
||||||
organizationId: auth.organization.id,
|
|
||||||
id: roleId,
|
|
||||||
...(typeof body.label === "string" ? { label: body.label } : {}),
|
|
||||||
...(typeof body.defaultModelId === "string" ? { defaultModelId: body.defaultModelId } : {}),
|
|
||||||
...(body.defaultModelId === null ? { defaultModelId: null } : {}),
|
|
||||||
...(typeof body.systemPrompt === "string" ? { systemPrompt: body.systemPrompt } : {}),
|
|
||||||
...(body.systemPrompt === null ? { systemPrompt: null } : {}),
|
|
||||||
...(Array.isArray(body.tools) ? { tools: body.tools as readonly string[] } : {}),
|
|
||||||
...(body.tools === null ? { tools: null } : {}),
|
|
||||||
...(typeof body.roleId === "string" ? { roleId: body.roleId } : {}),
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete("/api/org/:orgSlug/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;
|
|
||||||
return await deleteOrgRole(config.prisma, {
|
|
||||||
organizationId: auth.organization.id,
|
|
||||||
id: roleId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return handleRouteError(reply, err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseProviderMode(value: unknown): ProviderMode | null {
|
|
||||||
if (typeof value !== "string") return null;
|
|
||||||
return (PROVIDER_MODES as readonly string[]).includes(value) ? (value as ProviderMode) : null;
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
import { registerAccessRoutes } from "./accessRoutes.js";
|
import { registerAccessRoutes } from "./accessRoutes.js";
|
||||||
import { registerExplorerRoutes } from "./explorerRoutes.js";
|
import { registerExplorerRoutes } from "./explorerRoutes.js";
|
||||||
import { registerMembersRoutes } from "./membersRoutes.js";
|
import { registerMembersRoutes } from "./membersRoutes.js";
|
||||||
import { registerModelRoutes } from "./modelRoutes.js";
|
|
||||||
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
||||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||||
@@ -106,10 +105,6 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
|||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
});
|
});
|
||||||
await registerModelRoutes(app, {
|
|
||||||
prisma: config.prisma,
|
|
||||||
sessionSecret: config.sessionSecret,
|
|
||||||
});
|
|
||||||
await registerSessionsAndUsageRoutes(app, {
|
await registerSessionsAndUsageRoutes(app, {
|
||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
|
|||||||
@@ -1,386 +0,0 @@
|
|||||||
/**
|
|
||||||
* Org-scoped agent config: provider connection, enabled models, and role
|
|
||||||
* presets (ADR-0017 / ADR-0021).
|
|
||||||
*
|
|
||||||
* Roles and models are **data** (ADR-0017): admin/teacher-defined, persisted
|
|
||||||
* per organization. The runtime registry (`settings/runtime.ts`) reads these
|
|
||||||
* rows, falling back to env-based defaults when an org has none configured.
|
|
||||||
*
|
|
||||||
* Provider credential storage is a pilot placeholder: the spec leaves key/base
|
|
||||||
* URL encryption, rotation and resolver mechanism OPEN (secret-control-plane).
|
|
||||||
* BYOK tokens are stored as opaque strings here; platform-managed credentials
|
|
||||||
* are resolved from process env, not stored in this table.
|
|
||||||
*/
|
|
||||||
import type { PrismaClient } from "@prisma/client";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
import { assertSupportedRoleTools } from "../agent/roleTools.js";
|
|
||||||
|
|
||||||
export interface ProviderConnectionRow {
|
|
||||||
readonly organizationId: string;
|
|
||||||
readonly providerId: string;
|
|
||||||
readonly mode: "BYOK" | "PLATFORM_MANAGED";
|
|
||||||
readonly baseUrl: string | null;
|
|
||||||
readonly hasAuthToken: boolean;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getProviderConnection(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
organizationId: string,
|
|
||||||
): Promise<ProviderConnectionRow> {
|
|
||||||
const row = await prisma.organizationProviderConnection.findUnique({
|
|
||||||
where: { organizationId },
|
|
||||||
});
|
|
||||||
if (row === null) {
|
|
||||||
return {
|
|
||||||
organizationId,
|
|
||||||
providerId: "openrouter",
|
|
||||||
mode: "PLATFORM_MANAGED",
|
|
||||||
baseUrl: null,
|
|
||||||
hasAuthToken: false,
|
|
||||||
createdAt: "",
|
|
||||||
updatedAt: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
organizationId: row.organizationId,
|
|
||||||
providerId: row.providerId,
|
|
||||||
mode: row.mode,
|
|
||||||
baseUrl: row.baseUrl,
|
|
||||||
hasAuthToken: row.authToken !== null && row.authToken !== "",
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertProviderConnection(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
input: {
|
|
||||||
readonly organizationId: string;
|
|
||||||
readonly providerId?: string | undefined;
|
|
||||||
readonly mode: "BYOK" | "PLATFORM_MANAGED";
|
|
||||||
readonly baseUrl?: string | null | undefined;
|
|
||||||
readonly authToken?: string | null | undefined;
|
|
||||||
},
|
|
||||||
): Promise<ProviderConnectionRow> {
|
|
||||||
const providerId = input.providerId ?? "openrouter";
|
|
||||||
if (providerId.trim() === "") throw new Error("providerId is required");
|
|
||||||
|
|
||||||
const baseUrl =
|
|
||||||
input.baseUrl === undefined || input.baseUrl === null
|
|
||||||
? null
|
|
||||||
: input.baseUrl.trim() === ""
|
|
||||||
? null
|
|
||||||
: input.baseUrl.trim();
|
|
||||||
|
|
||||||
const authToken =
|
|
||||||
input.authToken === undefined || input.authToken === null
|
|
||||||
? undefined
|
|
||||||
: input.authToken === ""
|
|
||||||
? null
|
|
||||||
: input.authToken;
|
|
||||||
|
|
||||||
const row = await prisma.organizationProviderConnection.upsert({
|
|
||||||
where: { organizationId: input.organizationId },
|
|
||||||
create: {
|
|
||||||
organizationId: input.organizationId,
|
|
||||||
providerId,
|
|
||||||
mode: input.mode,
|
|
||||||
baseUrl,
|
|
||||||
...(authToken !== undefined ? { authToken } : {}),
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
providerId,
|
|
||||||
mode: input.mode,
|
|
||||||
baseUrl,
|
|
||||||
...(authToken !== undefined ? { authToken } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
organizationId: row.organizationId,
|
|
||||||
providerId: row.providerId,
|
|
||||||
mode: row.mode,
|
|
||||||
baseUrl: row.baseUrl,
|
|
||||||
hasAuthToken: row.authToken !== null && row.authToken !== "",
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrgModelRow {
|
|
||||||
readonly id: string;
|
|
||||||
readonly modelId: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly toolCapable: boolean;
|
|
||||||
readonly sortKey: string;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listOrgModels(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
organizationId: string,
|
|
||||||
): Promise<readonly OrgModelRow[]> {
|
|
||||||
const rows = await prisma.orgModel.findMany({
|
|
||||||
where: { organizationId },
|
|
||||||
orderBy: [{ sortKey: "asc" }, { createdAt: "asc" }],
|
|
||||||
});
|
|
||||||
return rows.map(toModelRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createOrgModel(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
input: {
|
|
||||||
readonly organizationId: string;
|
|
||||||
readonly modelId: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly toolCapable?: boolean | undefined;
|
|
||||||
readonly sortKey?: string | undefined;
|
|
||||||
},
|
|
||||||
): Promise<OrgModelRow> {
|
|
||||||
const modelId = requireNonEmpty(input.modelId, "modelId");
|
|
||||||
const label = requireNonEmpty(input.label, "label");
|
|
||||||
const existing = await prisma.orgModel.findUnique({
|
|
||||||
where: { organizationId_modelId: { organizationId: input.organizationId, modelId } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (existing !== null) {
|
|
||||||
throw new Error(`model already exists: ${modelId}`);
|
|
||||||
}
|
|
||||||
const row = await prisma.orgModel.create({
|
|
||||||
data: {
|
|
||||||
organizationId: input.organizationId,
|
|
||||||
modelId,
|
|
||||||
label,
|
|
||||||
...(input.toolCapable !== undefined ? { toolCapable: input.toolCapable } : {}),
|
|
||||||
...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return toModelRow(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateOrgModel(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
input: {
|
|
||||||
readonly organizationId: string;
|
|
||||||
readonly id: string;
|
|
||||||
readonly label?: string | undefined;
|
|
||||||
readonly toolCapable?: boolean | undefined;
|
|
||||||
readonly sortKey?: string | undefined;
|
|
||||||
readonly modelId?: string | undefined;
|
|
||||||
},
|
|
||||||
): Promise<OrgModelRow> {
|
|
||||||
const row = await prisma.orgModel.findUnique({ where: { id: input.id } });
|
|
||||||
if (row === null || row.organizationId !== input.organizationId) {
|
|
||||||
throw new Error(`model not found: ${input.id}`);
|
|
||||||
}
|
|
||||||
const updated = await prisma.orgModel.update({
|
|
||||||
where: { id: row.id },
|
|
||||||
data: {
|
|
||||||
...(input.label !== undefined ? { label: requireNonEmpty(input.label, "label") } : {}),
|
|
||||||
...(input.toolCapable !== undefined ? { toolCapable: input.toolCapable } : {}),
|
|
||||||
...(input.sortKey !== undefined ? { sortKey: input.sortKey } : {}),
|
|
||||||
...(input.modelId !== undefined
|
|
||||||
? { modelId: requireNonEmpty(input.modelId, "modelId") }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return toModelRow(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteOrgModel(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
input: { readonly organizationId: string; readonly id: string },
|
|
||||||
): Promise<{ readonly deleted: true; readonly id: string }> {
|
|
||||||
const row = await prisma.orgModel.findUnique({ where: { id: input.id } });
|
|
||||||
if (row === null || row.organizationId !== input.organizationId) {
|
|
||||||
throw new Error(`model not found: ${input.id}`);
|
|
||||||
}
|
|
||||||
await prisma.orgModel.delete({ where: { id: row.id } });
|
|
||||||
return { deleted: true as const, id: row.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OrgRoleRow {
|
|
||||||
readonly id: string;
|
|
||||||
readonly roleId: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly defaultModelId: string | null;
|
|
||||||
readonly systemPrompt: string | null;
|
|
||||||
readonly tools: readonly string[] | null;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listOrgRoles(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
organizationId: string,
|
|
||||||
): Promise<readonly OrgRoleRow[]> {
|
|
||||||
const rows = await prisma.orgRole.findMany({
|
|
||||||
where: { organizationId },
|
|
||||||
orderBy: [{ roleId: "asc" }],
|
|
||||||
});
|
|
||||||
return rows.map(toRoleRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createOrgRole(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
input: {
|
|
||||||
readonly organizationId: string;
|
|
||||||
readonly roleId: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly defaultModelId?: string | null | undefined;
|
|
||||||
readonly systemPrompt?: string | null | undefined;
|
|
||||||
readonly tools?: readonly string[] | null | undefined;
|
|
||||||
},
|
|
||||||
): Promise<OrgRoleRow> {
|
|
||||||
const roleId = requireNonEmpty(input.roleId, "roleId");
|
|
||||||
const label = requireNonEmpty(input.label, "label");
|
|
||||||
const existing = await prisma.orgRole.findUnique({
|
|
||||||
where: { organizationId_roleId: { organizationId: input.organizationId, roleId } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (existing !== null) {
|
|
||||||
throw new Error(`role already exists: ${roleId}`);
|
|
||||||
}
|
|
||||||
const tools = normalizeTools(input.tools);
|
|
||||||
const row = await prisma.orgRole.create({
|
|
||||||
data: {
|
|
||||||
organizationId: input.organizationId,
|
|
||||||
roleId,
|
|
||||||
label,
|
|
||||||
...(input.defaultModelId !== undefined && input.defaultModelId !== null
|
|
||||||
? { defaultModelId: input.defaultModelId }
|
|
||||||
: {}),
|
|
||||||
...(input.defaultModelId === null ? { defaultModelId: null } : {}),
|
|
||||||
...(input.systemPrompt !== undefined && input.systemPrompt !== null
|
|
||||||
? { systemPrompt: input.systemPrompt }
|
|
||||||
: {}),
|
|
||||||
...(input.systemPrompt === null ? { systemPrompt: null } : {}),
|
|
||||||
...(tools !== undefined ? { tools: tools ?? Prisma.DbNull } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return toRoleRow(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateOrgRole(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
input: {
|
|
||||||
readonly organizationId: string;
|
|
||||||
readonly id: string;
|
|
||||||
readonly label?: string | undefined;
|
|
||||||
readonly defaultModelId?: string | null | undefined;
|
|
||||||
readonly systemPrompt?: string | null | undefined;
|
|
||||||
readonly tools?: readonly string[] | null | undefined;
|
|
||||||
readonly roleId?: string | undefined;
|
|
||||||
},
|
|
||||||
): Promise<OrgRoleRow> {
|
|
||||||
const row = await prisma.orgRole.findUnique({ where: { id: input.id } });
|
|
||||||
if (row === null || row.organizationId !== input.organizationId) {
|
|
||||||
throw new Error(`role not found: ${input.id}`);
|
|
||||||
}
|
|
||||||
const tools = normalizeTools(input.tools);
|
|
||||||
const updated = await prisma.orgRole.update({
|
|
||||||
where: { id: row.id },
|
|
||||||
data: {
|
|
||||||
...(input.label !== undefined ? { label: requireNonEmpty(input.label, "label") } : {}),
|
|
||||||
...(input.defaultModelId !== undefined && input.defaultModelId !== null
|
|
||||||
? { defaultModelId: input.defaultModelId }
|
|
||||||
: {}),
|
|
||||||
...(input.defaultModelId === null ? { defaultModelId: null } : {}),
|
|
||||||
...(input.systemPrompt !== undefined && input.systemPrompt !== null
|
|
||||||
? { systemPrompt: input.systemPrompt }
|
|
||||||
: {}),
|
|
||||||
...(input.systemPrompt === null ? { systemPrompt: null } : {}),
|
|
||||||
...(tools !== undefined ? { tools: tools ?? Prisma.DbNull } : {}),
|
|
||||||
...(input.roleId !== undefined ? { roleId: requireNonEmpty(input.roleId, "roleId") } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return toRoleRow(updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteOrgRole(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
input: { readonly organizationId: string; readonly id: string },
|
|
||||||
): Promise<{ readonly deleted: true; readonly id: string }> {
|
|
||||||
const row = await prisma.orgRole.findUnique({ where: { id: input.id } });
|
|
||||||
if (row === null || row.organizationId !== input.organizationId) {
|
|
||||||
throw new Error(`role not found: ${input.id}`);
|
|
||||||
}
|
|
||||||
await prisma.orgRole.delete({ where: { id: row.id } });
|
|
||||||
return { deleted: true as const, id: row.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
function toModelRow(
|
|
||||||
row: {
|
|
||||||
readonly id: string;
|
|
||||||
readonly modelId: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly toolCapable: boolean;
|
|
||||||
readonly sortKey: string;
|
|
||||||
readonly createdAt: Date;
|
|
||||||
readonly updatedAt: Date;
|
|
||||||
},
|
|
||||||
): OrgModelRow {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
modelId: row.modelId,
|
|
||||||
label: row.label,
|
|
||||||
toolCapable: row.toolCapable,
|
|
||||||
sortKey: row.sortKey,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRoleRow(
|
|
||||||
row: {
|
|
||||||
readonly id: string;
|
|
||||||
readonly roleId: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly defaultModelId: string | null;
|
|
||||||
readonly systemPrompt: string | null;
|
|
||||||
readonly tools: unknown;
|
|
||||||
readonly createdAt: Date;
|
|
||||||
readonly updatedAt: Date;
|
|
||||||
},
|
|
||||||
): OrgRoleRow {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
roleId: row.roleId,
|
|
||||||
label: row.label,
|
|
||||||
defaultModelId: row.defaultModelId,
|
|
||||||
systemPrompt: row.systemPrompt,
|
|
||||||
tools: parseTools(row.tools),
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
updatedAt: row.updatedAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a tools input into a value safe for prisma `tools` (Json?) column.
|
|
||||||
* - `undefined` ⇒ omit (leave unchanged): returns `undefined`.
|
|
||||||
* - `null` ⇒ unrestricted role (SQL NULL): returns `null`.
|
|
||||||
* - array ⇒ validated against the supported role tool set (fails fast).
|
|
||||||
*/
|
|
||||||
function normalizeTools(
|
|
||||||
tools: readonly string[] | null | undefined,
|
|
||||||
): readonly string[] | null | undefined {
|
|
||||||
if (tools === undefined) return undefined;
|
|
||||||
if (tools === null) return null;
|
|
||||||
const list = [...tools];
|
|
||||||
assertSupportedRoleTools(list);
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTools(tools: unknown): readonly string[] | null {
|
|
||||||
if (tools === null) return null;
|
|
||||||
if (!Array.isArray(tools)) return null;
|
|
||||||
return tools.filter((t): t is string => typeof t === "string");
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireNonEmpty(value: string, label: string): string {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (trimmed === "") throw new Error(`${label} is required`);
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
/**
|
|
||||||
* Org agent-config API tests: provider connection, models, roles (ADR-0017 /
|
|
||||||
* ADR-0021). Also covers the DB-backed runtime registry wiring.
|
|
||||||
*/
|
|
||||||
import Fastify from "fastify";
|
|
||||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
|
||||||
import { registerAdminPlugin } from "../../src/admin/plugin.js";
|
|
||||||
import {
|
|
||||||
mintSessionToken,
|
|
||||||
sessionCookieHeader,
|
|
||||||
} from "../../src/admin/routes/authRoutes.js";
|
|
||||||
import { createDbRuntimeSettings } from "../../src/settings/runtime.js";
|
|
||||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
|
||||||
|
|
||||||
const SESSION_SECRET = "integration-test-session-secret";
|
|
||||||
|
|
||||||
async function buildApp() {
|
|
||||||
const app = Fastify({ logger: false });
|
|
||||||
await registerAdminPlugin(app, {
|
|
||||||
prisma,
|
|
||||||
sessionSecret: SESSION_SECRET,
|
|
||||||
publicBaseUrl: "http://127.0.0.1:8788",
|
|
||||||
feishuAppId: "cli_test",
|
|
||||||
feishuAppSecret: "secret_test",
|
|
||||||
projectWorkspaceRoot: "/tmp/cph-test-ws",
|
|
||||||
cookieSecure: false,
|
|
||||||
});
|
|
||||||
await app.ready();
|
|
||||||
return app;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function seedOwner(): Promise<string> {
|
|
||||||
await prisma.user.create({
|
|
||||||
data: { id: "u-owner", feishuOpenId: "ou_owner", displayName: "Owner" },
|
|
||||||
});
|
|
||||||
await prisma.organizationMembership.create({
|
|
||||||
data: { organizationId: DEFAULT_ORG_ID, userId: "u-owner", role: "OWNER" },
|
|
||||||
});
|
|
||||||
return mintSessionToken({ userId: "u-owner", feishuOpenId: "ou_owner" }, SESSION_SECRET);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("admin agent-config API (provider/models/roles)", () => {
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDb();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("upserts a BYOK provider connection and reads it back without exposing the token", async () => {
|
|
||||||
const token = await seedOwner();
|
|
||||||
const app = await buildApp();
|
|
||||||
try {
|
|
||||||
const cookie = sessionCookieHeader(token);
|
|
||||||
|
|
||||||
const put = await app.inject({
|
|
||||||
method: "PUT",
|
|
||||||
url: "/api/org/test-default/provider-connection",
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: { mode: "BYOK", baseUrl: "https://gw.test/api", authToken: "secret-token-123" },
|
|
||||||
});
|
|
||||||
expect(put.statusCode).toBe(200);
|
|
||||||
const conn = put.json() as { mode: string; baseUrl: string; hasAuthToken: boolean };
|
|
||||||
expect(conn.mode).toBe("BYOK");
|
|
||||||
expect(conn.baseUrl).toBe("https://gw.test/api");
|
|
||||||
expect(conn.hasAuthToken).toBe(true);
|
|
||||||
|
|
||||||
const get = await app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: "/api/org/test-default/provider-connection",
|
|
||||||
headers: { cookie },
|
|
||||||
});
|
|
||||||
const got = get.json() as { mode: string; hasAuthToken: boolean };
|
|
||||||
expect(got.mode).toBe("BYOK");
|
|
||||||
expect(got.hasAuthToken).toBe(true);
|
|
||||||
// Token value must never be returned by GET.
|
|
||||||
expect(get.body).not.toContain("secret-token-123");
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates, updates and deletes an enabled model", async () => {
|
|
||||||
const token = await seedOwner();
|
|
||||||
const app = await buildApp();
|
|
||||||
try {
|
|
||||||
const cookie = sessionCookieHeader(token);
|
|
||||||
|
|
||||||
const create = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/api/org/test-default/models",
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: { modelId: "anthropic/claude-sonnet-5", label: "Sonnet 5" },
|
|
||||||
});
|
|
||||||
expect(create.statusCode).toBe(201);
|
|
||||||
const model = create.json() as { id: string; modelId: string };
|
|
||||||
expect(model.modelId).toBe("anthropic/claude-sonnet-5");
|
|
||||||
|
|
||||||
// Duplicate modelId is rejected.
|
|
||||||
const dup = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/api/org/test-default/models",
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: { modelId: "anthropic/claude-sonnet-5", label: "dup" },
|
|
||||||
});
|
|
||||||
expect(dup.statusCode).toBe(400);
|
|
||||||
|
|
||||||
const upd = await app.inject({
|
|
||||||
method: "PATCH",
|
|
||||||
url: `/api/org/test-default/models/${model.id}`,
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: { label: "Sonnet 5 (tool)", toolCapable: true, sortKey: "10" },
|
|
||||||
});
|
|
||||||
expect(upd.statusCode).toBe(200);
|
|
||||||
expect((upd.json() as { label: string }).label).toBe("Sonnet 5 (tool)");
|
|
||||||
|
|
||||||
const list = await app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: "/api/org/test-default/models",
|
|
||||||
headers: { cookie },
|
|
||||||
});
|
|
||||||
expect((list.json() as { models: unknown[] }).models).toHaveLength(1);
|
|
||||||
|
|
||||||
const del = await app.inject({
|
|
||||||
method: "DELETE",
|
|
||||||
url: `/api/org/test-default/models/${model.id}`,
|
|
||||||
headers: { cookie },
|
|
||||||
});
|
|
||||||
expect(del.statusCode).toBe(200);
|
|
||||||
const after = await app.inject({
|
|
||||||
method: "GET",
|
|
||||||
url: "/api/org/test-default/models",
|
|
||||||
headers: { cookie },
|
|
||||||
});
|
|
||||||
expect((after.json() as { models: unknown[] }).models).toHaveLength(0);
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a role with tools whitelist and rejects unknown tool ids", async () => {
|
|
||||||
const token = await seedOwner();
|
|
||||||
const app = await buildApp();
|
|
||||||
try {
|
|
||||||
const cookie = sessionCookieHeader(token);
|
|
||||||
|
|
||||||
const create = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/api/org/test-default/roles",
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: {
|
|
||||||
roleId: "draft",
|
|
||||||
label: "草稿",
|
|
||||||
systemPrompt: "You are a teaching drafter.",
|
|
||||||
tools: ["read_file", "write_file", "cph_check"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(create.statusCode).toBe(201);
|
|
||||||
const role = create.json() as { id: string; roleId: string; tools: string[] };
|
|
||||||
expect(role.roleId).toBe("draft");
|
|
||||||
expect(role.tools).toEqual(["read_file", "write_file", "cph_check"]);
|
|
||||||
|
|
||||||
// Unknown tool id is rejected.
|
|
||||||
const bad = await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/api/org/test-default/roles",
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: { roleId: "bad", label: "bad", tools: ["not_a_real_tool"] },
|
|
||||||
});
|
|
||||||
expect(bad.statusCode).toBe(500);
|
|
||||||
|
|
||||||
// null tools ⇒ unrestricted.
|
|
||||||
const unrestricted = await app.inject({
|
|
||||||
method: "PATCH",
|
|
||||||
url: `/api/org/test-default/roles/${role.id}`,
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: { tools: null },
|
|
||||||
});
|
|
||||||
expect(unrestricted.statusCode).toBe(200);
|
|
||||||
expect((unrestricted.json() as { tools: string[] | null }).tools).toBeNull();
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("DbRuntimeSettings reads org-scoped models/roles and falls back to env", async () => {
|
|
||||||
const token = await seedOwner();
|
|
||||||
const app = await buildApp();
|
|
||||||
try {
|
|
||||||
const cookie = sessionCookieHeader(token);
|
|
||||||
|
|
||||||
await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/api/org/test-default/models",
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: { modelId: "z-ai/glm-4.7", label: "GLM", toolCapable: true },
|
|
||||||
});
|
|
||||||
await app.inject({
|
|
||||||
method: "POST",
|
|
||||||
url: "/api/org/test-default/roles",
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: { roleId: "draft", label: "草稿", defaultModelId: "z-ai/glm-4.7" },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.project.create({
|
|
||||||
data: { id: "p-cfg", organizationId: DEFAULT_ORG_ID, name: "CfgProj", workspaceDir: "/tmp/p-cfg" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const settings = createDbRuntimeSettings(prisma, {
|
|
||||||
ANTHROPIC_AUTH_TOKEN: "env-token",
|
|
||||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5",
|
|
||||||
});
|
|
||||||
const registry = await settings.modelRegistry({ projectId: "p-cfg" });
|
|
||||||
const models = registry.listModels();
|
|
||||||
expect(models.map((m) => m.id)).toContain("z-ai/glm-4.7");
|
|
||||||
const draft = registry.role("draft");
|
|
||||||
expect(draft?.label).toBe("草稿");
|
|
||||||
expect(registry.resolve(undefined, "draft")).toBe("z-ai/glm-4.7");
|
|
||||||
|
|
||||||
// Unknown project ⇒ env fallback registry.
|
|
||||||
const fallback = await settings.modelRegistry({ projectId: "no-such-project" });
|
|
||||||
expect(fallback.listModels().map((m) => m.id)).toContain("anthropic/claude-sonnet-5");
|
|
||||||
|
|
||||||
// BYOK provider connection resolves org credentials.
|
|
||||||
await app.inject({
|
|
||||||
method: "PUT",
|
|
||||||
url: "/api/org/test-default/provider-connection",
|
|
||||||
headers: { cookie, "content-type": "application/json" },
|
|
||||||
payload: { mode: "BYOK", baseUrl: "https://gw.test/api", authToken: "byok-token" },
|
|
||||||
});
|
|
||||||
const provider = await settings.provider("openrouter", { projectId: "p-cfg" });
|
|
||||||
expect(provider.baseUrl).toBe("https://gw.test/api");
|
|
||||||
expect(provider.authToken).toBe("byok-token");
|
|
||||||
} finally {
|
|
||||||
await app.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user