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>; 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; } export interface ProviderLeaseContext { readonly providerId: string; readonly projectId: string; readonly runId: string; } export type ProviderLeaseFactory = ( credential: ProviderUpstreamCredential, context: ProviderLeaseContext, ) => Promise; 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; modelRegistry(scope?: RuntimeScope): Promise; runPolicy(input: RunPolicyInput): Promise; } 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 { void providerId; void scope; throw new Error( "process-global provider credentials are disabled; use the project-scoped provider resolver", ); } async modelRegistry(scope?: RuntimeScope): Promise { void scope; return createDefaultModelRegistry(this.envSource()); } async runPolicy(input: RunPolicyInput): Promise { 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 { 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 { return this.envSettings.modelRegistry(scope); } runPolicy(input: RunPolicyInput): Promise { 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; }