forked from EduCraft/curriculum-project-hub
feat(hub): 抽出运行时配置
This commit is contained in:
@@ -1,32 +1,5 @@
|
||||
import { InMemoryModelRegistry } from "./models.js";
|
||||
|
||||
type Env = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
|
||||
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
|
||||
|
||||
function readEnv(env: Env, name: string): string | undefined {
|
||||
const value = env[name]?.trim();
|
||||
return value === undefined || value === "" ? undefined : value;
|
||||
}
|
||||
|
||||
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 },
|
||||
],
|
||||
);
|
||||
}
|
||||
export {
|
||||
createDefaultModelRegistry,
|
||||
defaultSonnetModel,
|
||||
type Env,
|
||||
} from "../settings/runtime.js";
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface RunRequest {
|
||||
readonly model: string | undefined;
|
||||
readonly project: ProjectContext;
|
||||
readonly systemPrompt: string | undefined;
|
||||
readonly providerEnv?: Record<string, string | undefined> | undefined;
|
||||
readonly resumeSessionId?: string | undefined;
|
||||
readonly mcpServers?: Record<string, McpServerConfig> | undefined;
|
||||
readonly maxTurns?: number;
|
||||
@@ -146,6 +147,7 @@ export async function runAgent(req: RunRequest): Promise<RunResult> {
|
||||
};
|
||||
if (req.systemPrompt !== undefined) options.systemPrompt = req.systemPrompt;
|
||||
if (req.model !== undefined) options.model = req.model;
|
||||
if (req.providerEnv !== undefined) options.env = { ...process.env, ...req.providerEnv };
|
||||
if (req.resumeSessionId !== undefined) options.resume = req.resumeSessionId;
|
||||
if (req.mcpServers !== undefined) options.mcpServers = req.mcpServers;
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import { sendText, sendTextMessage, patchTextMessage, reactToMessage, downloadMessageFile, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
||||
import type { ModelRegistry } from "../agent/models.js";
|
||||
import { runAgent as defaultRunAgent, type ProjectContext, type RunRequest, type RunResult } from "../agent/runner.js";
|
||||
import type { RuntimeSettings } from "../settings/runtime.js";
|
||||
import { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
||||
import { writeAudit } from "../audit.js";
|
||||
@@ -22,7 +22,7 @@ import { createFileDeliveryMcpServer } from "./fileDeliveryTool.js";
|
||||
|
||||
interface TriggerDeps {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly models: ModelRegistry;
|
||||
readonly settings: RuntimeSettings;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
readonly runAgent?: (req: RunRequest) => Promise<RunResult>;
|
||||
}
|
||||
@@ -183,7 +183,8 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
// ADR-0017: role-as-data. Parse a leading `/<role>` command; unknown
|
||||
// slashes are left as literal text (extractRole returns null). Falls back
|
||||
// to "draft" when no role is named — the registry degrades gracefully.
|
||||
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, deps.models);
|
||||
const models = await deps.settings.modelRegistry({ projectId });
|
||||
const { roleId: parsedRole, prompt: agentPrompt } = extractRole(cleanPrompt, models);
|
||||
const roleId = parsedRole ?? "draft";
|
||||
// Per-role trigger gate (ADR-0017). Orthogonal to ADR-0004's
|
||||
// canTriggerAgent above: that checked "can trigger an agent at all";
|
||||
@@ -195,9 +196,11 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
await sendText(rt, chatId, `无权限使用角色 ${roleId}。`);
|
||||
return;
|
||||
}
|
||||
const role = deps.models.role(roleId);
|
||||
const model = deps.models.resolve(undefined, roleId);
|
||||
const providerId = "openrouter";
|
||||
const role = models.role(roleId);
|
||||
const model = models.resolve(undefined, roleId);
|
||||
const provider = await deps.settings.provider("openrouter", { projectId });
|
||||
const runPolicy = await deps.settings.runPolicy({ projectId, roleId });
|
||||
const providerId = provider.id;
|
||||
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
|
||||
// is enforced inside runAgent when it builds the SDK tools record. `tools:
|
||||
// undefined` means the full registered set (unrestricted role).
|
||||
@@ -289,8 +292,10 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
||||
model,
|
||||
project: projectCtx,
|
||||
systemPrompt,
|
||||
providerEnv: provider.sdkEnv,
|
||||
resumeSessionId: sessionMetadata.claudeSessionId,
|
||||
mcpServers: { cph_hub: fileDeliveryMcpServer },
|
||||
maxTurns: runPolicy.maxTurns,
|
||||
runId: run.id,
|
||||
sessionId: session.id,
|
||||
prisma: deps.prisma,
|
||||
|
||||
+5
-13
@@ -13,7 +13,7 @@
|
||||
import "dotenv/config";
|
||||
import Fastify from "fastify";
|
||||
import { prisma } from "./db.js";
|
||||
import { createDefaultModelRegistry } from "./agent/defaultModels.js";
|
||||
import { createEnvRuntimeSettings } from "./settings/runtime.js";
|
||||
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
||||
import { makeTriggerHandler } from "./feishu/trigger.js";
|
||||
|
||||
@@ -36,11 +36,9 @@ async function main(): Promise<void> {
|
||||
const databaseUrl = requireEnv("DATABASE_URL");
|
||||
void databaseUrl;
|
||||
|
||||
// Claude Code SDK auth via OpenRouter's Anthropic Skin.
|
||||
// ANTHROPIC_AUTH_TOKEN = OpenRouter API key; ANTHROPIC_API_KEY must be empty.
|
||||
requireEnv("ANTHROPIC_AUTH_TOKEN");
|
||||
process.env["ANTHROPIC_BASE_URL"] ??= "https://openrouter.ai/api";
|
||||
process.env["ANTHROPIC_API_KEY"] ??= "";
|
||||
const runtimeSettings = createEnvRuntimeSettings();
|
||||
await runtimeSettings.provider("openrouter");
|
||||
|
||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||
@@ -56,20 +54,14 @@ async function main(): Promise<void> {
|
||||
});
|
||||
app.log.info("startup: cleared stale locks + dead runs");
|
||||
|
||||
|
||||
|
||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||
|
||||
// Model registry (role-as-data, ADR-0017). Defaults are OpenRouter model
|
||||
// slugs; ANTHROPIC_DEFAULT_SONNET_MODEL can override the role default.
|
||||
const models = createDefaultModelRegistry();
|
||||
|
||||
// --- Feishu listener ---
|
||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||
if (feishuListenerEnabled) {
|
||||
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||
const larkClient = createLarkClient(feishuConfig);
|
||||
const trigger = makeTriggerHandler({ prisma, models, logger: app.log });
|
||||
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log });
|
||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
|
||||
} else {
|
||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js";
|
||||
|
||||
export type Env = Readonly<Record<string, string | undefined>>;
|
||||
|
||||
type EnvSource = Env | (() => Env);
|
||||
|
||||
const DEFAULT_OPENROUTER_BASE_URL = "https://openrouter.ai/api";
|
||||
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
|
||||
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
|
||||
const DEFAULT_AGENT_MAX_TURNS = 25;
|
||||
|
||||
export interface ProviderRuntimeSettings {
|
||||
readonly id: string;
|
||||
readonly baseUrl: string;
|
||||
readonly authToken: string;
|
||||
readonly anthropicApiKey: string;
|
||||
readonly sdkEnv: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export interface RuntimeScope {
|
||||
readonly projectId?: string;
|
||||
}
|
||||
|
||||
export interface RunPolicyInput {
|
||||
readonly projectId: string;
|
||||
readonly roleId: string;
|
||||
}
|
||||
|
||||
export interface RunPolicy {
|
||||
readonly maxTurns: 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 scope;
|
||||
if (providerId !== "openrouter") {
|
||||
throw new Error(`unknown provider runtime settings: ${providerId}`);
|
||||
}
|
||||
const env = this.envSource();
|
||||
const baseUrl = readEnv(env, "ANTHROPIC_BASE_URL") ?? DEFAULT_OPENROUTER_BASE_URL;
|
||||
const authToken = requireSetting(env, "ANTHROPIC_AUTH_TOKEN");
|
||||
const anthropicApiKey = readEnv(env, "ANTHROPIC_API_KEY") ?? "";
|
||||
|
||||
return {
|
||||
id: providerId,
|
||||
baseUrl,
|
||||
authToken,
|
||||
anthropicApiKey,
|
||||
sdkEnv: {
|
||||
ANTHROPIC_BASE_URL: baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: authToken,
|
||||
ANTHROPIC_API_KEY: anthropicApiKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createEnvRuntimeSettings(env: EnvSource = process.env): RuntimeSettings {
|
||||
return new EnvRuntimeSettings(env);
|
||||
}
|
||||
|
||||
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 requireSetting(env: Env, name: string): string {
|
||||
const value = readEnv(env, name);
|
||||
if (value === undefined) {
|
||||
throw new Error(`missing required runtime setting: ${name}`);
|
||||
}
|
||||
return 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;
|
||||
}
|
||||
Reference in New Issue
Block a user