forked from EduCraft/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 { registerExplorerRoutes } from "./explorerRoutes.js";
|
||||
import { registerMembersRoutes } from "./membersRoutes.js";
|
||||
import { registerModelRoutes } from "./modelRoutes.js";
|
||||
import { registerSessionsAndUsageRoutes } from "./sessionsRoutes.js";
|
||||
import { registerTeamsRoutes } from "./teamsRoutes.js";
|
||||
import { registerProviderConnectionRoutes } from "./providerConnectionRoutes.js";
|
||||
@@ -106,10 +105,6 @@ export async function registerOrgRoutes(app: FastifyInstance, config: OrgRouteCo
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerModelRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
});
|
||||
await registerSessionsAndUsageRoutes(app, {
|
||||
prisma: config.prisma,
|
||||
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