forked from bai/curriculum-project-hub
249 lines
8.1 KiB
TypeScript
249 lines
8.1 KiB
TypeScript
import type { Prisma, PrismaClient } from "@prisma/client";
|
|
import { InMemoryModelRegistry, type ModelRegistry } 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 },
|
|
);
|
|
}),
|
|
};
|
|
}
|
|
|
|
modelRegistry(scope?: RuntimeScope): Promise<ModelRegistry> {
|
|
return this.envSettings.modelRegistry(scope);
|
|
}
|
|
|
|
runPolicy(input: RunPolicyInput): Promise<RunPolicy> {
|
|
return this.envSettings.runPolicy(input);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|