From 3f486232a873732d2d2b8e7e554d1bc0fd81f88f Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 8 Jul 2026 15:25:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(hub):=20=E6=8A=BD=E5=87=BA=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/.env.example | 3 + hub/src/agent/defaultModels.ts | 37 +------ hub/src/agent/runner.ts | 2 + hub/src/feishu/trigger.ts | 17 ++-- hub/src/server.ts | 18 +--- hub/src/settings/runtime.ts | 128 +++++++++++++++++++++++++ hub/test/integration/trigger.test.ts | 63 +++++++++--- hub/test/unit/runner.test.ts | 30 ++++++ hub/test/unit/runtime-settings.test.ts | 65 +++++++++++++ 9 files changed, 297 insertions(+), 66 deletions(-) create mode 100644 hub/src/settings/runtime.ts create mode 100644 hub/test/unit/runtime-settings.test.ts diff --git a/hub/.env.example b/hub/.env.example index e8024ec..1675953 100644 --- a/hub/.env.example +++ b/hub/.env.example @@ -18,6 +18,9 @@ ANTHROPIC_API_KEY="" # ANTHROPIC_DEFAULT_OPUS_MODEL="z-ai/glm-4.7" # ANTHROPIC_DEFAULT_HAIKU_MODEL="z-ai/glm-4.6" +# Agent run policy (optional). Defaults to 25. +# HUB_AGENT_MAX_TURNS=25 + # Feishu (lark) bot credentials. FEISHU_APP_ID="" FEISHU_APP_SECRET="" diff --git a/hub/src/agent/defaultModels.ts b/hub/src/agent/defaultModels.ts index 91103e6..cb1dfb8 100644 --- a/hub/src/agent/defaultModels.ts +++ b/hub/src/agent/defaultModels.ts @@ -1,32 +1,5 @@ -import { InMemoryModelRegistry } from "./models.js"; - -type Env = Readonly>; - -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"; diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index b2c33d8..6ece973 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -50,6 +50,7 @@ export interface RunRequest { readonly model: string | undefined; readonly project: ProjectContext; readonly systemPrompt: string | undefined; + readonly providerEnv?: Record | undefined; readonly resumeSessionId?: string | undefined; readonly mcpServers?: Record | undefined; readonly maxTurns?: number; @@ -146,6 +147,7 @@ export async function runAgent(req: RunRequest): Promise { }; 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; diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 4e6dd5c..b2a9d80 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -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; } @@ -183,7 +183,8 @@ export function makeTriggerHandler(deps: TriggerDeps) { // ADR-0017: role-as-data. Parse a leading `/` 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, diff --git a/hub/src/server.ts b/hub/src/server.ts index 1136505..534238a 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -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 { 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 { }); 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"); diff --git a/hub/src/settings/runtime.ts b/hub/src/settings/runtime.ts new file mode 100644 index 0000000..30345d8 --- /dev/null +++ b/hub/src/settings/runtime.ts @@ -0,0 +1,128 @@ +import { InMemoryModelRegistry, type ModelRegistry } from "../agent/models.js"; + +export type Env = Readonly>; + +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; +} + +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; + 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 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 { + 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), + }; + } +} + +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; +} diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 3796435..f85b80a 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -4,10 +4,35 @@ import { InMemoryModelRegistry } from "../../src/agent/models.js"; import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import type { MessageReceiveEvent } from "../../src/feishu/client.js"; import type { RunRequest, RunResult } from "../../src/agent/runner.js"; +import type { RuntimeSettings } from "../../src/settings/runtime.js"; const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" }; type TestRunner = (req: RunRequest) => Promise; +function makeTestSettings(models: InMemoryModelRegistry): RuntimeSettings { + return { + async provider(providerId) { + return { + id: providerId, + baseUrl: "https://openrouter.ai/api", + authToken: "test-token", + anthropicApiKey: "", + sdkEnv: { + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + ANTHROPIC_AUTH_TOKEN: "test-token", + ANTHROPIC_API_KEY: "", + }, + }; + }, + async modelRegistry() { + return models; + }, + async runPolicy() { + return { maxTurns: 7 }; + }, + }; +} + function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user", eventId?: string): MessageReceiveEvent { const header = eventId !== undefined ? { event_id: eventId, event_type: "im.message.receive_v1" } : undefined; return { @@ -49,6 +74,7 @@ function createMockRunAgent(calls: RunRequest[] = []): TestRunner { describe("trigger full lifecycle (integration)", () => { let models: InMemoryModelRegistry; + let settings: RuntimeSettings; let rt: ReturnType; let runAgentCalls: RunRequest[]; let runAgent: TestRunner; @@ -64,12 +90,13 @@ describe("trigger full lifecycle (integration)", () => { { id: "review", label: "审校", defaultModel: "mock-model", systemPrompt: undefined, tools: ["read_file"] }, ], ); + settings = makeTestSettings(models); rt = mockFeishuRuntime(); }); it("creates a run, acquires + releases the lock, sends status card", async () => { await seedProject("proj-1", "chat-1"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt); @@ -90,11 +117,17 @@ describe("trigger full lifecycle (integration)", () => { expect(rt.sentCards.length).toBeGreaterThanOrEqual(1); expect(rt.sentTexts).toContain("mock response"); expect(runAgentCalls).toHaveLength(1); + expect(runAgentCalls[0]?.providerEnv).toMatchObject({ + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + ANTHROPIC_AUTH_TOKEN: "test-token", + ANTHROPIC_API_KEY: "", + }); + expect(runAgentCalls[0]?.maxTurns).toBe(7); }); it("rejects a sender without edit grant (ADR-0004)", async () => { await seedProject("proj-2", "chat-2", { role: "READ" }); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt); @@ -114,7 +147,7 @@ describe("trigger full lifecycle (integration)", () => { data: { projectId: "proj-3", runId: existingRun.id }, }); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt); expect(rt.sentTexts).toContain("项目正在处理中,请稍候。"); @@ -126,7 +159,7 @@ describe("trigger full lifecycle (integration)", () => { it("ignores messages from unbound chats (ADR-0001)", async () => { await seedProject("proj-4", "chat-4"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt); @@ -138,7 +171,7 @@ describe("trigger full lifecycle (integration)", () => { it("ignores messages without @bot mention", async () => { await seedProject("proj-5", "chat-5"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); const event: MessageReceiveEvent = { message: { @@ -160,7 +193,7 @@ describe("trigger full lifecycle (integration)", () => { it("/new archives current session (no run created)", async () => { await seedProject("proj-6", "chat-6"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); // First @bot creates a session + run. await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt); @@ -183,7 +216,7 @@ describe("trigger full lifecycle (integration)", () => { it("/resume un-archives the most recent session", async () => { await seedProject("proj-7", "chat-7"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); // Create + archive a session via /new. await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt); @@ -203,7 +236,7 @@ describe("trigger full lifecycle (integration)", () => { it("/reset archives current session", async () => { await seedProject("proj-8", "chat-8"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt); await vi.waitFor(async () => { @@ -222,7 +255,7 @@ describe("trigger full lifecycle (integration)", () => { it("unknown slash command falls through to agent", async () => { await seedProject("proj-9", "chat-9"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt); // Should create a run (falls through as a normal prompt). @@ -239,7 +272,7 @@ describe("trigger full lifecycle (integration)", () => { await prisma.roleTriggerGrant.create({ data: { projectId: "proj-10", roleId: "review", principal: "ou_other" }, }); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt); @@ -254,7 +287,7 @@ describe("trigger full lifecycle (integration)", () => { await prisma.roleTriggerGrant.create({ data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" }, }); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt); @@ -268,7 +301,7 @@ describe("trigger full lifecycle (integration)", () => { it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => { await seedProject("proj-12", "chat-12"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt); @@ -282,7 +315,7 @@ describe("trigger full lifecycle (integration)", () => { it("dedups a redelivered event by event_id (no second run)", async () => { await seedProject("proj-13", "chat-13"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); // First delivery: processes normally. await trigger(makeEvent("chat-13", "@_user_1 写教案", "ou_test_user", "evt-dedup-1"), rt); @@ -305,7 +338,7 @@ describe("trigger full lifecycle (integration)", () => { it("writes audit entries across the run lifecycle", async () => { await seedProject("proj-14", "chat-14"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-14", "@_user_1 写教案"), rt); await vi.waitFor(async () => { @@ -320,7 +353,7 @@ describe("trigger full lifecycle (integration)", () => { it("persists AgentMessage rows for the run (A-3 structured history)", async () => { await seedProject("proj-15", "chat-15"); - const trigger = makeTriggerHandler({ prisma, models, logger: silentLogger, runAgent }); + const trigger = makeTriggerHandler({ prisma, settings, logger: silentLogger, runAgent }); await trigger(makeEvent("chat-15", "@_user_1 写教案"), rt); await vi.waitFor(async () => { diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts index f738524..bbc0242 100644 --- a/hub/test/unit/runner.test.ts +++ b/hub/test/unit/runner.test.ts @@ -91,6 +91,36 @@ describe("runAgent", () => { expect(call?.options).not.toHaveProperty("resume"); }); + it("passes provider env per run without mutating process env", async () => { + delete process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]; + queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); + + await runAgent({ + prompt: "你好", + model: undefined, + project: { projectId: "p", boundChatId: "c", workspaceDir: "/tmp/ws" }, + systemPrompt: undefined, + providerEnv: { + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + ANTHROPIC_AUTH_TOKEN: "test-token", + ANTHROPIC_API_KEY: "", + CPH_TEST_PROVIDER_ENV_MUTATION: "per-run", + }, + runId: "run-1", + sessionId: "hub-session-1", + prisma: stubPrisma, + }); + + const call = queryMock.mock.calls[0]?.[0] as { options?: { env?: Record } } | undefined; + expect(call?.options?.env).toMatchObject({ + ANTHROPIC_BASE_URL: "https://openrouter.ai/api", + ANTHROPIC_AUTH_TOKEN: "test-token", + ANTHROPIC_API_KEY: "", + CPH_TEST_PROVIDER_ENV_MUTATION: "per-run", + }); + expect(process.env["CPH_TEST_PROVIDER_ENV_MUTATION"]).toBeUndefined(); + }); + it("persists structured user and assistant messages best-effort", async () => { queryMock.mockReturnValue(messages(assistantMessage("ok"), resultMessage("sdk-session-1"))); const createMessage = vi.fn().mockResolvedValue({}); diff --git a/hub/test/unit/runtime-settings.test.ts b/hub/test/unit/runtime-settings.test.ts new file mode 100644 index 0000000..0fa9438 --- /dev/null +++ b/hub/test/unit/runtime-settings.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { createEnvRuntimeSettings } from "../../src/settings/runtime.js"; + +describe("runtime settings", () => { + it("reads the model registry from the current env at call time", async () => { + const env: Record = { + ANTHROPIC_DEFAULT_SONNET_MODEL: "anthropic/claude-sonnet-5", + }; + const settings = createEnvRuntimeSettings(env); + + expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("anthropic/claude-sonnet-5"); + + env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = "z-ai/glm-4.7"; + + expect((await settings.modelRegistry()).resolve(undefined, "draft")).toBe("z-ai/glm-4.7"); + }); + + it("returns provider SDK env without requiring global process env mutation", async () => { + const settings = createEnvRuntimeSettings({ + ANTHROPIC_BASE_URL: "https://example.test/api", + ANTHROPIC_AUTH_TOKEN: "openrouter-token", + ANTHROPIC_API_KEY: "explicit-api-key", + }); + + await expect(settings.provider("openrouter")).resolves.toMatchObject({ + id: "openrouter", + baseUrl: "https://example.test/api", + authToken: "openrouter-token", + anthropicApiKey: "explicit-api-key", + sdkEnv: { + ANTHROPIC_BASE_URL: "https://example.test/api", + ANTHROPIC_AUTH_TOKEN: "openrouter-token", + ANTHROPIC_API_KEY: "explicit-api-key", + }, + }); + }); + + it("defaults Anthropic API key to empty for OpenRouter's Anthropic skin", async () => { + const settings = createEnvRuntimeSettings({ + ANTHROPIC_AUTH_TOKEN: "openrouter-token", + }); + + await expect(settings.provider("openrouter")).resolves.toMatchObject({ + baseUrl: "https://openrouter.ai/api", + anthropicApiKey: "", + sdkEnv: { + ANTHROPIC_API_KEY: "", + }, + }); + }); + + it("requires an auth token for the OpenRouter provider", async () => { + const settings = createEnvRuntimeSettings({}); + + await expect(settings.provider("openrouter")).rejects.toThrow("missing required runtime setting: ANTHROPIC_AUTH_TOKEN"); + }); + + it("honors HUB_AGENT_MAX_TURNS as a runtime run policy", async () => { + const settings = createEnvRuntimeSettings({ + HUB_AGENT_MAX_TURNS: "9", + }); + + await expect(settings.runPolicy({ projectId: "p", roleId: "draft" })).resolves.toEqual({ maxTurns: 9 }); + }); +});