forked from EduCraft/curriculum-project-hub
35251986af
The admin role model picker was hardcoded to the env-default model registry (createDefaultModelRegistry), which only ever returned a single Sonnet model. Roles could not select any other model regardless of what the org's provider connection supported. Replace the env-only model list with a ProviderModelCatalog that: - Resolves the org's ACTIVE provider connection credential (BYOK or platform-managed, encrypted via ADR-0024 envelope) - Calls OpenRouter GET /v1/models?supported_parameters=tools to list tool-capable models available to that org - Caches results in-memory with a 5-minute TTL per organization - Falls back to the env-default registry when no ACTIVE provider exists The runtime modelRegistry no longer validates role.defaultModel against the env model list — the admin already validated by selection from the provider catalog. The env list remains as the fallback for roles with null defaultModel. The admin roles page loads models independently (non-blocking) so roles remain editable even if the provider API is slow or unreachable.
329 lines
12 KiB
TypeScript
329 lines
12 KiB
TypeScript
import type { Prisma, PrismaClient } from "@prisma/client";
|
|
import { InMemoryModelRegistry, type ModelRegistry, type RoleEntry, type RoleSkillEntry } from "../agent/models.js";
|
|
import { lockActiveOrganization } from "../org/status.js";
|
|
import { decryptStoredProviderCredential } from "../connections/providerConnections.js";
|
|
import { openProviderProxyLease, type AgentProviderLease, type ProviderUpstreamCredential } from "../connections/providerProxy.js";
|
|
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
|
|
|
export type Env = Readonly<Record<string, string | undefined>>;
|
|
|
|
type EnvSource = Env | (() => Env);
|
|
|
|
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
|
|
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
|
|
const DEFAULT_AGENT_MAX_TURNS = 25;
|
|
const DEFAULT_AGENT_MAX_CONCURRENT_RUNS = 1;
|
|
const DEFAULT_AGENT_MAX_RUN_SECONDS = 900;
|
|
|
|
export interface ProviderRuntimeSettings {
|
|
readonly id: string;
|
|
openAgentLease(input: { readonly runId: string }): Promise<AgentProviderLease>;
|
|
}
|
|
|
|
export interface ProviderLeaseContext {
|
|
readonly providerId: string;
|
|
readonly projectId: string;
|
|
readonly runId: string;
|
|
}
|
|
|
|
export type ProviderLeaseFactory = (
|
|
credential: ProviderUpstreamCredential,
|
|
context: ProviderLeaseContext,
|
|
) => Promise<AgentProviderLease>;
|
|
|
|
const DEFAULT_PROVIDER_LEASE_FACTORY: ProviderLeaseFactory = (credential) =>
|
|
openProviderProxyLease(credential);
|
|
|
|
export interface RuntimeScope {
|
|
readonly projectId?: string;
|
|
}
|
|
|
|
export interface RunPolicyInput {
|
|
readonly projectId: string;
|
|
readonly roleId: string;
|
|
}
|
|
|
|
export interface RunPolicy {
|
|
readonly maxTurns: number;
|
|
readonly maxConcurrentRuns: number;
|
|
readonly maxRunSeconds: number;
|
|
}
|
|
|
|
export interface RuntimeSettings {
|
|
provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings>;
|
|
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry>;
|
|
runPolicy(input: RunPolicyInput): Promise<RunPolicy>;
|
|
}
|
|
|
|
export class EnvRuntimeSettings implements RuntimeSettings {
|
|
private readonly envSource: () => Env;
|
|
|
|
constructor(env: EnvSource = process.env) {
|
|
this.envSource = typeof env === "function" ? env : () => env;
|
|
}
|
|
|
|
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
|
|
void providerId;
|
|
void scope;
|
|
throw new Error(
|
|
"process-global provider credentials are disabled; use the project-scoped provider resolver",
|
|
);
|
|
}
|
|
|
|
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
|
|
void scope;
|
|
return createDefaultModelRegistry(this.envSource());
|
|
}
|
|
|
|
async runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
|
void input;
|
|
return {
|
|
maxTurns: positiveIntegerEnv(this.envSource(), "HUB_AGENT_MAX_TURNS", DEFAULT_AGENT_MAX_TURNS),
|
|
maxConcurrentRuns: positiveIntegerEnv(
|
|
this.envSource(),
|
|
"HUB_AGENT_MAX_CONCURRENT_RUNS",
|
|
DEFAULT_AGENT_MAX_CONCURRENT_RUNS,
|
|
),
|
|
maxRunSeconds: positiveIntegerEnv(
|
|
this.envSource(),
|
|
"HUB_AGENT_MAX_RUN_SECONDS",
|
|
DEFAULT_AGENT_MAX_RUN_SECONDS,
|
|
),
|
|
};
|
|
}
|
|
}
|
|
|
|
export class DatabaseRuntimeSettings implements RuntimeSettings {
|
|
private readonly envSettings: EnvRuntimeSettings;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaClient,
|
|
private readonly secrets: LocalSecretEnvelope,
|
|
env: EnvSource = process.env,
|
|
private readonly leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY,
|
|
) {
|
|
this.envSettings = new EnvRuntimeSettings(env);
|
|
}
|
|
|
|
async provider(providerId: string, scope?: RuntimeScope): Promise<ProviderRuntimeSettings> {
|
|
const projectId = scope?.projectId?.trim();
|
|
if (projectId === undefined || projectId === "") {
|
|
throw new Error("projectId is required to resolve provider credentials");
|
|
}
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await loadActiveProviderSecret(tx, projectId, providerId);
|
|
});
|
|
|
|
return {
|
|
id: providerId,
|
|
openAgentLease: async ({ runId }) => this.prisma.$transaction(async (tx) => {
|
|
const resolved = await loadActiveProviderSecret(tx, projectId, providerId);
|
|
const secretVersion = resolved.connection.activeSecretVersion;
|
|
const credential = decryptStoredProviderCredential(this.secrets, {
|
|
organizationId: resolved.organizationId,
|
|
connectionId: resolved.connection.id,
|
|
providerId,
|
|
secretVersionId: secretVersion.id,
|
|
envelopeVersion: secretVersion.envelopeVersion,
|
|
keyId: secretVersion.keyId,
|
|
envelope: secretVersion.envelope,
|
|
});
|
|
return this.leaseFactory(
|
|
{
|
|
baseUrl: credential.baseUrl,
|
|
authToken: credential.authToken,
|
|
anthropicApiKey: credential.anthropicApiKey,
|
|
},
|
|
{ providerId, projectId, runId },
|
|
);
|
|
}),
|
|
};
|
|
}
|
|
|
|
async modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
|
|
const projectId = scope?.projectId?.trim();
|
|
if (projectId === undefined || projectId === "") {
|
|
throw new Error("projectId is required to resolve Agent runtime configuration");
|
|
}
|
|
const project = await this.prisma.project.findUnique({
|
|
where: { id: projectId },
|
|
select: {
|
|
archivedAt: true,
|
|
organization: {
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
agentRoles: {
|
|
where: { disabledAt: null },
|
|
orderBy: [{ sortOrder: "asc" }, { roleId: "asc" }],
|
|
include: {
|
|
skillBindings: {
|
|
orderBy: [{ sortOrder: "asc" }, { agentSkillId: "asc" }],
|
|
include: { skill: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (project === null || project.archivedAt !== null) {
|
|
throw new Error(`active project not found: ${projectId}`);
|
|
}
|
|
if (project.organization.status !== "ACTIVE") {
|
|
throw new Error(`organization ${project.organization.id} is ${project.organization.status}`);
|
|
}
|
|
if (project.organization.agentRoles.length === 0) {
|
|
throw new Error(`no active Agent roles configured for organization ${project.organization.id}`);
|
|
}
|
|
const defaultRoles = project.organization.agentRoles.filter((role) => role.isDefault);
|
|
if (defaultRoles.length !== 1) {
|
|
throw new Error(`organization ${project.organization.id} must have exactly one active default Agent role`);
|
|
}
|
|
|
|
// The model list is the env-based fallback used when a role has no
|
|
// explicit defaultModel. Role defaultModels are chosen from the provider
|
|
// model catalog (admin surface) and may not be in the env list — we don't
|
|
// validate against it at runtime; the admin already validated by selection.
|
|
const defaults = await this.envSettings.modelRegistry(scope);
|
|
const fallbackModels = defaults.listModels();
|
|
const roles: RoleEntry[] = project.organization.agentRoles.map((role) => ({
|
|
id: role.roleId,
|
|
label: role.label,
|
|
defaultModel: role.defaultModel ?? undefined,
|
|
...(role.systemPrompt !== null ? { systemPrompt: role.systemPrompt } : {}),
|
|
...(role.tools !== null ? { tools: roleToolsFromJson(role.roleId, role.tools) } : {}),
|
|
skills: role.skillBindings.map((binding): RoleSkillEntry => {
|
|
if (binding.skill.disabledAt !== null) {
|
|
throw new Error(`role ${role.roleId} selects disabled skill ${binding.skill.name}`);
|
|
}
|
|
if (!/^[a-f0-9]{64}$/.test(binding.skill.contentDigest)) {
|
|
throw new Error(`skill ${binding.skill.name} has invalid content digest`);
|
|
}
|
|
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(binding.skill.name)) {
|
|
throw new Error(`skill has invalid name: ${binding.skill.name}`);
|
|
}
|
|
if (binding.skill.version.trim() === "") {
|
|
throw new Error(`skill ${binding.skill.name} has empty version`);
|
|
}
|
|
return {
|
|
name: binding.skill.name,
|
|
version: binding.skill.version,
|
|
contentDigest: binding.skill.contentDigest,
|
|
};
|
|
}),
|
|
}));
|
|
return new InMemoryModelRegistry(fallbackModels, roles);
|
|
}
|
|
|
|
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
|
return this.envSettings.runPolicy(input);
|
|
}
|
|
}
|
|
|
|
function roleToolsFromJson(roleId: string, value: Prisma.JsonValue): readonly string[] {
|
|
if (!Array.isArray(value) || value.some((tool) => typeof tool !== "string")) {
|
|
throw new Error(`role ${roleId} tools must be a JSON string array`);
|
|
}
|
|
return value as string[];
|
|
}
|
|
|
|
|
|
async function loadActiveProviderSecret(
|
|
tx: Prisma.TransactionClient,
|
|
projectId: string,
|
|
providerId: string,
|
|
): Promise<{
|
|
readonly organizationId: string;
|
|
readonly connection: {
|
|
readonly id: string;
|
|
readonly activeSecretVersion: {
|
|
readonly id: string;
|
|
readonly connectionId: string;
|
|
readonly envelopeVersion: number;
|
|
readonly keyId: string;
|
|
readonly envelope: Prisma.JsonValue;
|
|
readonly retiredAt: Date | null;
|
|
};
|
|
};
|
|
}> {
|
|
const project = await tx.project.findUnique({
|
|
where: { id: projectId },
|
|
select: { organizationId: true, archivedAt: true },
|
|
});
|
|
if (project === null || project.archivedAt !== null) {
|
|
throw new Error(`active project not found: ${projectId}`);
|
|
}
|
|
await lockActiveOrganization(tx, project.organizationId);
|
|
|
|
const connection = await tx.organizationProviderConnection.findUnique({
|
|
where: {
|
|
organizationId_providerId: {
|
|
organizationId: project.organizationId,
|
|
providerId,
|
|
},
|
|
},
|
|
include: { activeSecretVersion: true },
|
|
});
|
|
const activeSecretVersion = connection?.activeSecretVersion;
|
|
if (connection === null || connection.status !== "ACTIVE" ||
|
|
activeSecretVersion === null || activeSecretVersion === undefined || activeSecretVersion.retiredAt !== null ||
|
|
activeSecretVersion.connectionId !== connection.id) {
|
|
throw new Error(`active provider connection not found: ${providerId} for project ${projectId}`);
|
|
}
|
|
return {
|
|
organizationId: project.organizationId,
|
|
connection: { id: connection.id, activeSecretVersion },
|
|
};
|
|
}
|
|
|
|
export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings {
|
|
return new EnvRuntimeSettings(env);
|
|
}
|
|
|
|
export function createDatabaseRuntimeSettings(
|
|
prisma: PrismaClient,
|
|
secrets: LocalSecretEnvelope,
|
|
env: EnvSource = process.env,
|
|
leaseFactory: ProviderLeaseFactory = DEFAULT_PROVIDER_LEASE_FACTORY,
|
|
): RuntimeSettings {
|
|
return new DatabaseRuntimeSettings(prisma, secrets, env, leaseFactory);
|
|
}
|
|
|
|
export function defaultSonnetModel(env: Env = process.env): string {
|
|
return readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL") ?? DEFAULT_SONNET_MODEL;
|
|
}
|
|
|
|
export function createDefaultModelRegistry(env: Env = process.env): InMemoryModelRegistry {
|
|
const sonnetModel = defaultSonnetModel(env);
|
|
const sonnetLabel =
|
|
readEnv(env, "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME") ??
|
|
(sonnetModel === DEFAULT_SONNET_MODEL ? DEFAULT_SONNET_LABEL : sonnetModel);
|
|
|
|
return new InMemoryModelRegistry(
|
|
[
|
|
{ id: sonnetModel, label: sonnetLabel, toolCapable: true },
|
|
],
|
|
[
|
|
{ id: "draft", label: "草稿", defaultModel: sonnetModel },
|
|
{ id: "review", label: "审校", defaultModel: sonnetModel },
|
|
],
|
|
);
|
|
}
|
|
|
|
function readEnv(env: Env, name: string): string | undefined {
|
|
const value = env[name]?.trim();
|
|
return value === undefined || value === "" ? undefined : value;
|
|
}
|
|
|
|
function positiveIntegerEnv(env: Env, name: string, fallback: number): number {
|
|
const raw = readEnv(env, name);
|
|
if (raw === undefined) return fallback;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
throw new Error(`runtime setting ${name} must be a positive integer`);
|
|
}
|
|
return value;
|
|
}
|