Files
curriculum-project-hub/hub/src/settings/runtime.ts
T
hongjr03 35251986af feat(hub): derive admin model picker from org provider connection via OpenRouter API
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.
2026-07-16 01:24:00 +08:00

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;
}