From 3e37ac8ae77f267c5b2402673405451236abdb93 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 13:45:40 +0800 Subject: [PATCH] refactor(agent): runAgent uses Agent upstreams only; deprecate legacy settings.pi fields pi.provider / baseUrl / model / thinkingLevel / apiKeySecretRef are now DEPRECATED_SETTINGS_FIELDS: kept in the schema for stored JSON, dropped from PATCH /settings and from the settings sections, never read by the server. runAgent fails with a clear message when no upstream is set. The agent demo gate checks the default upstream provider; e2e seeds a fake upstream as default. OpenAPI summaries say Agent upstream. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/modules/agent/fake-scripts.ts | 2 +- apps/server/src/modules/agent/runner.ts | 42 +++++++----------- .../modules/auth-settings/settings-routes.ts | 25 ++--------- apps/server/src/modules/pi/service.ts | 2 +- .../test/auth-settings/admin-api.test.ts | 30 ++++++------- apps/server/test/e2e/harness.ts | 19 +++++++- apps/server/test/pi/upstreams.test.ts | 10 ++++- apps/web/app/api/agent.ts | 12 +++--- .../app/components/settings/pi-upstreams.tsx | 8 +--- docs/runbook/agent.md | 8 ++-- packages/contracts/src/api.ts | 10 ++--- packages/contracts/src/settings.ts | 43 ++++++++++++------- packages/contracts/test/pi.test.ts | 12 ++++-- packages/contracts/test/schemas.test.ts | 6 +-- .../contracts/test/settings-sections.test.ts | 8 +++- 15 files changed, 122 insertions(+), 115 deletions(-) diff --git a/apps/server/src/modules/agent/fake-scripts.ts b/apps/server/src/modules/agent/fake-scripts.ts index af1d78b2..3fd604ff 100644 --- a/apps/server/src/modules/agent/fake-scripts.ts +++ b/apps/server/src/modules/agent/fake-scripts.ts @@ -1,7 +1,7 @@ import type { FakeScript, FakeStep, FakeToolResult } from "@umt/agent"; import { z } from "zod"; -// fake 模式脚本(pi.provider = "fake"):测试直接注入 FakeScript;设置里的 agent.fakeScript 是可序列化子集(JSON): +// fake 模式脚本(默认上游 provider = "fake"):测试直接注入 FakeScript;设置里的 agent.fakeScript 是可序列化子集(JSON): // { "match": "收进来" | { "regex": "..." }, "steps": [{ "text"?, "toolCalls": [{ "capability", "input" }] }], "finalText": "..." } // 或它们的数组。input 里的字符串可以写 "{{download.plan.plan.id}}":取该能力最近一次成功结果里的路径值。 diff --git a/apps/server/src/modules/agent/runner.ts b/apps/server/src/modules/agent/runner.ts index 628e34c0..a4ea65ec 100644 --- a/apps/server/src/modules/agent/runner.ts +++ b/apps/server/src/modules/agent/runner.ts @@ -117,31 +117,17 @@ export interface DriveResult { /** * 设置 → @umt/agent 的运行参数。按上游运行时(W7-A)凭据由上游的 CredentialStore 在请求时解密注入,这里不取明文; - * 没有任何上游时回退 v0.1 旧字段(pi.provider / pi.apiKeySecretRef,apiKey 现场经 secrets 解密,不落日志)。 + * 只走上游:v0.1 的旧字段(pi.provider / model / apiKeySecretRef)已弃用,不再读取(W8-A)。 */ -async function agentSettings( - env: AgentEnv, - s: GlobalSettings, - budget: AgentBudget, - fake: boolean, - upstream: RunUpstream | null, -): Promise { +function agentSettings(s: GlobalSettings, budget: AgentBudget, fake: boolean, upstream: RunUpstream): AgentSettings { const pi = s.pi; - let apiKey: string | undefined; - if (!fake && !upstream) { - if (!pi.apiKeySecretRef) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)"); - apiKey = await env.ctx.secrets.resolve(pi.apiKeySecretRef); - if (!pi.model) throw new Error("未配置模型(设置 → Agent → 上游)"); - } const maxTokens = budget.maxTokens ?? s.agent.maxTokensPerRun ?? undefined; const maxCost = budget.maxCostUsd ?? s.agent.maxCostUsdPerRun ?? undefined; - const up = upstream?.row; + const up = upstream.row; return { - provider: up ? up.provider : pi.provider === "openai-compatible" ? "openai-compatible" : "anthropic", - ...(!up && pi.baseUrl && !fake ? { baseUrl: pi.baseUrl } : {}), - ...(apiKey ? { apiKey } : fake && !up ? { apiKey: "fake-key-not-used" } : {}), - model: (up ? up.default_model : pi.model) ?? (fake ? "fake-model" : "default"), - thinkingLevel: up ? up.thinking_level : pi.thinkingLevel, + provider: up.provider, + model: up.default_model ?? (fake ? "fake-model" : "default"), + thinkingLevel: up.thinking_level, contextBudgetTokens: pi.contextBudget, compaction: { mode: pi.compaction.enabled ? "threshold" : "manual", @@ -329,14 +315,16 @@ export async function drive( if (blocked) return { status: "failed", reason: "budget", budget: "daily", message: blocked, finalText: "" }; const scope = (run.scope ?? {}) as unknown as RunScope; - // 上游:run.upstream_id(runAgent 的 upstreamId 覆盖)> settings.pi.defaultUpstreamId > 旧字段兼容 - let upstream: RunUpstream | null; + // 上游:run.upstream_id(runAgent 的 upstreamId 覆盖)> settings.pi.defaultUpstreamId;都没有直接失败 + let upstream: RunUpstream; let fake: boolean; let settings: AgentSettings; try { - upstream = await resolveRunUpstream(ctx, run.upstream_id); - fake = upstream ? upstream.row.provider === "fake" : s.pi.provider === "fake"; - settings = await agentSettings(env, s, (run.budget ?? {}) as AgentBudget, fake, upstream); + const resolved = await resolveRunUpstream(ctx, run.upstream_id); + if (!resolved) throw new Error("未配置 Agent 上游(设置 → Agent → 上游)"); + upstream = resolved; + fake = upstream.row.provider === "fake"; + settings = agentSettings(s, (run.budget ?? {}) as AgentBudget, fake, upstream); } catch (err) { return { status: "failed", reason: "error", budget: null, message: (err as Error).message, finalText: "" }; } @@ -346,9 +334,9 @@ export async function drive( status: "running", started_at: run.started_at ?? new Date(), provider: fake ? "fake" : settings.provider, - model: upstream ? (upstream.row.default_model ?? (fake ? "fake-model" : null)) : fake ? "fake-model" : s.pi.model, + model: upstream.row.default_model ?? (fake ? "fake-model" : null), thinking_level: settings.thinkingLevel ?? null, - ...(upstream ? { upstream_id: upstream.row.id } : {}), + upstream_id: upstream.row.id, }) .where("id", "=", runId) .execute(); diff --git a/apps/server/src/modules/auth-settings/settings-routes.ts b/apps/server/src/modules/auth-settings/settings-routes.ts index ae99abeb..294bda93 100644 --- a/apps/server/src/modules/auth-settings/settings-routes.ts +++ b/apps/server/src/modules/auth-settings/settings-routes.ts @@ -5,7 +5,8 @@ import { audit, endpoint } from "./endpoint.ts"; import { type CachedSettingsReader, SETTINGS_CHANGED_EVENT } from "./provided.ts"; // GET/PATCH /settings:按分组部分更新 → mergeSettings → GlobalSettings 整体校验 → 逐组 upsert; -// Pi 上游密钥(pi.apiKey 只写)存为 secret,设置里只留 apiKeySecretRef;变更经 SSE 总线广播 settings.changed, +// TMDB 密钥(metadata.tmdbApiKey 只写)存为 secret,设置里只留 tmdbApiKeySecretRef(Agent 上游密钥在 pi_upstream, +// 旧 pi.apiKey 已弃用,PATCH 里会被 schema 丢弃);变更经 SSE 总线广播 settings.changed, // 各进程的 ctx.settings 缓存随之失效。自动化总开关 automation.enabled / 只读对账 automation.readOnlyReconcile 也走这里。 const isCached = (s: unknown): s is CachedSettingsReader => @@ -21,15 +22,7 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) { const patch = body as GlobalSettingsUpdate; const current = isCached(ctx.settings) ? await ctx.settings.fresh() : await ctx.settings.all(); const merged = mergeSettings(current, patch); - const oldRef = current.pi.apiKeySecretRef; - let dropRef: string | null = null; - let newRef: string | null = null; - const apiKey = patch.pi?.apiKey; - if (apiKey === null) { - merged.pi.apiKeySecretRef = null; - dropRef = oldRef; - } - // TMDB 密钥同样只写:存 secret(metadata_api_key),设置里只留 tmdbApiKeySecretRef + // TMDB 密钥只写:存 secret(metadata_api_key),设置里只留 tmdbApiKeySecretRef const oldTmdbRef = current.metadata.tmdbApiKeySecretRef; const tmdbKey = patch.metadata?.tmdbApiKey; let dropTmdbRef: string | null = null; @@ -44,15 +37,6 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) { throw new ApiException("validation_failed", msg); } const next = parsed.data; - if (typeof apiKey === "string") { - if (oldRef) { - await ctx.secrets.replace(oldRef, apiKey); - next.pi.apiKeySecretRef = oldRef; - } else { - newRef = await ctx.secrets.create({ purpose: "llm_api_key", plaintext: apiKey }); - next.pi.apiKeySecretRef = newRef; - } - } if (typeof tmdbKey === "string") { if (oldTmdbRef) { await ctx.secrets.replace(oldTmdbRef, tmdbKey); @@ -76,17 +60,14 @@ export function registerSettingsRoutes(app: ModuleApp, ctx: ServerContext) { } }); } catch (err) { - if (newRef) await ctx.secrets.remove(newRef).catch(() => {}); if (newTmdbRef) await ctx.secrets.remove(newTmdbRef).catch(() => {}); throw err; } - if (dropRef) await ctx.secrets.remove(dropRef).catch(() => {}); if (dropTmdbRef) await ctx.secrets.remove(dropTmdbRef).catch(() => {}); if (isCached(ctx.settings)) ctx.settings.invalidate(); void ctx.events.publish(SETTINGS_CHANGED_EVENT, { keys }); // 审计里不留明文密钥 const logged: Record = { ...patch }; - if (patch.pi && "apiKey" in patch.pi) logged.pi = { ...patch.pi, apiKey: apiKey === null ? "(cleared)" : "(set)" }; if (patch.metadata && "tmdbApiKey" in patch.metadata) logged.metadata = { ...patch.metadata, tmdbApiKey: tmdbKey === null ? "(cleared)" : "(set)" }; await audit(ctx, actor, "settings.update", { type: "settings", id: keys.join(",") }, { patch: logged }); diff --git a/apps/server/src/modules/pi/service.ts b/apps/server/src/modules/pi/service.ts index b154995c..1d79b571 100644 --- a/apps/server/src/modules/pi/service.ts +++ b/apps/server/src/modules/pi/service.ts @@ -205,7 +205,7 @@ export interface RunUpstream { } /** - * 取运行用的上游:显式 upstreamId 优先,否则 settings.pi.defaultUpstreamId;都没有返回 null(调用方走旧字段兼容)。 + * 取运行用的上游:显式 upstreamId 优先,否则 settings.pi.defaultUpstreamId;都没有返回 null(runAgent 直接失败,旧 settings.pi 字段已弃用)。 * 上游停用 / 未登录 / 登录失效时抛出可读错误。 */ export async function resolveRunUpstream(ctx: ServerContext, upstreamId?: string | null): Promise { diff --git a/apps/server/test/auth-settings/admin-api.test.ts b/apps/server/test/auth-settings/admin-api.test.ts index 57d69d72..68995cde 100644 --- a/apps/server/test/auth-settings/admin-api.test.ts +++ b/apps/server/test/auth-settings/admin-api.test.ts @@ -94,24 +94,22 @@ describe.skipIf(!hasPg)("W2-A 管理 API(真实 PG)", () => { expect((await json(r)).error.message).toMatch(/恢复水位/); }); - it("Pi apiKey 只写:存为 secret,响应/审计/日志不含明文;null 清除并删除 secret", async () => { + it("旧 pi.apiKey / provider / model 不再接受:被丢弃,不建 secret,不改设置", async () => { const plain = "sk-live-super-secret-0001"; - const r = await json(await admin("PATCH", "/settings", { pi: { apiKey: plain, model: "claude-x" } })); - const ref = r.data.pi.apiKeySecretRef; - expect(ref).toMatch(/^[0-9a-f-]{36}$/); + const before = (await json(await admin("GET", "/settings"))).data.pi; + const r = await json( + await admin("PATCH", "/settings", { pi: { apiKey: plain, provider: "openai", model: "claude-x" } }), + ); + expect(r.data.pi).toMatchObject({ + provider: before.provider, + model: before.model, + apiKeySecretRef: before.apiKeySecretRef, + }); expect(JSON.stringify(r)).not.toContain(plain); - expect(await h.ctx.secrets.resolve(ref)).toBe(plain); - // 再改一次:原地轮换,ref 不变 - const r2 = await json(await admin("PATCH", "/settings", { pi: { apiKey: `${plain}-2` } })); - expect(r2.data.pi.apiKeySecretRef).toBe(ref); - expect(await h.ctx.secrets.resolve(ref)).toBe(`${plain}-2`); - const audits = await sql<{ - payload: unknown; - }>`select payload from audit_event where action = 'settings.update'`.execute(h.db); - expect(JSON.stringify(audits.rows)).not.toContain(plain); - const r3 = await json(await admin("PATCH", "/settings", { pi: { apiKey: null } })); - expect(r3.data.pi.apiKeySecretRef).toBeNull(); - await expect(h.ctx.secrets.resolve(ref)).rejects.toThrow(/不存在/); + const n = await sql<{ n: number }>`select count(*)::int as n from secret where purpose = 'llm_api_key'`.execute( + h.db, + ); + expect(n.rows[0]!.n).toBe(0); noLeak(plain); }); }); diff --git a/apps/server/test/e2e/harness.ts b/apps/server/test/e2e/harness.ts index c955d00d..479ac2eb 100644 --- a/apps/server/test/e2e/harness.ts +++ b/apps/server/test/e2e/harness.ts @@ -20,6 +20,7 @@ import { type Bootstrapped, bootstrap } from "../../src/context.ts"; import { createApp } from "../../src/http/app.ts"; import { EventBus } from "../../src/http/sse.ts"; import { createLogger } from "../../src/logger.ts"; +import { setDefaultUpstream } from "../../src/modules/pi/store.ts"; import type { ServerModule } from "../../src/modules/types.ts"; import { type RunningWorker, startWorker } from "../../src/worker/index.ts"; import { withSchemaLock } from "../auth-settings/harness.ts"; @@ -215,9 +216,23 @@ export async function createE2E(name: string, opts: E2EOptions = {}): Promise { const d3 = await drive(env, h.ctx.capabilities, r3, new AbortController().signal); expect(d3).toMatchObject({ status: "failed" }); expect(d3.message).toMatch(/已停用/); + + // 没有默认上游:旧 settings.pi 字段不再兜底,直接失败 + await setDefaultUpstream(h.ctx, null); + const r4 = await newRun(null); + const d4 = await drive(env, h.ctx.capabilities, r4, new AbortController().signal); + expect(d4).toMatchObject({ status: "failed" }); + expect(d4.message).toMatch(/未配置 Agent 上游/); + await setDefaultUpstream(h.ctx, fake.id); }); }); diff --git a/apps/web/app/api/agent.ts b/apps/web/app/api/agent.ts index 0c0a9dfb..83bd0236 100644 --- a/apps/web/app/api/agent.ts +++ b/apps/web/app/api/agent.ts @@ -24,12 +24,12 @@ import { MOCK_RUNS, mockCreateRun, mockDetail, mockEventsAfter } from "~/compone import { TTL_SECOND } from "~/lib/api.ts"; import { useSession } from "./auth.ts"; import { apiMutate, apiParsed, cleanParams } from "./parse.ts"; -import { useSettings } from "./settings.ts"; +import { usePiUpstreams } from "./pi.ts"; // Agent 会话(W2-D:GET/POST /agent/runs、GET /agent/runs/{id}?afterSeq=、/messages、/abort), // 形状一律取 contracts(AgentRun / AgentRunDetail / AgentGoalReport)。 // ?mock=1(或 VITE_AGENT_MOCK=1)只作演示开关:不连后端,用本地假数据预览交互; -// 仅开发构建或后端 pi.provider = "fake" 时生效,生产构建连真实模型时忽略(见 agentMockAllowed)。 +// 仅开发构建或后端默认上游是 fake 时生效,生产构建连真实模型时忽略(见 agentMockAllowed)。 export const AGENT_BASE = "/api/v1/agent/runs"; @@ -48,7 +48,7 @@ export type RunSnapshot = Omit; const RunPage = cursorPage(AgentRun); /** - * 演示开关是否可用:只在开发构建(import.meta.env.DEV)或后端 pi.provider = "fake"(脚本化假模型)时生效。 + * 演示开关是否可用:只在开发构建(import.meta.env.DEV)或后端默认上游 provider = "fake"(脚本化假模型)时生效。 * 生产构建连着真实模型时一律忽略 ?mock=1 / VITE_AGENT_MOCK,并清掉 sessionStorage 里记住的开关, * 避免把演示数据当成真实运行展示(验收 S7 / AT78)。 */ @@ -91,7 +91,7 @@ export function resolveAgentMock(o: { } } -/** 最近一次读到的后端 pi.provider 是否为 fake(由 useAgentMockGate 从 /settings 缓存同步) */ +/** 最近一次读到的后端默认上游是否为 fake(由 useAgentMockGate 从 /pi/upstreams 缓存同步) */ let fakeProvider = false; export function agentMockEnabled(): boolean { @@ -112,8 +112,8 @@ export function agentMockEnabled(): boolean { /** Agent 页的列表/详情 hook 里调用:同步后端 provider,再判断演示开关 */ function useAgentMockGate(): boolean { - const settings = useSettings(); - fakeProvider = settings.data?.pi?.provider === "fake"; + const upstreams = usePiUpstreams(); + fakeProvider = upstreams.data?.items.find((u) => u.isDefault)?.provider === "fake"; return agentMockEnabled(); } diff --git a/apps/web/app/components/settings/pi-upstreams.tsx b/apps/web/app/components/settings/pi-upstreams.tsx index a2f4e65c..71d24b47 100644 --- a/apps/web/app/components/settings/pi-upstreams.tsx +++ b/apps/web/app/components/settings/pi-upstreams.tsx @@ -10,7 +10,6 @@ import { useRemovePiUpstream, useUpdatePiUpstream, } from "~/api/pi.ts"; -import { useSettings } from "~/api/settings.ts"; import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx"; import { DataTable } from "~/components/console/data-table.tsx"; import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; @@ -39,12 +38,10 @@ import { SettingsBlock } from "./settings-block.tsx"; export function PiUpstreamsBlock() { const list = usePiUpstreams(); const providers = usePiProviders(false); - const settings = useSettings(); const [wizard, setWizard] = useState(false); const [openId, setOpenId] = useState(null); const open = list.data?.items.find((u) => u.id === openId) ?? null; const pv = providers.data?.providers; - const legacy = settings.data?.pi.provider; return ( list.refetch()} onRowClick={(r) => setOpenId(r.id)} - empty={{ - title: "还没有上游", - description: legacy ? `没有上游时按旧设置运行(provider:${legacy})。` : undefined, - }} + empty={{ title: "还没有上游" }} columns={[ { key: "name", diff --git a/docs/runbook/agent.md b/docs/runbook/agent.md index 658d59a0..475792e7 100644 --- a/docs/runbook/agent.md +++ b/docs/runbook/agent.md @@ -35,12 +35,12 @@ **增量推送**:事件先写 `agent_run_event`(run_id + seq 唯一),再经 `ctx.events` 推到全局 SSE(`GET /events`)。推送的类型有 `agent.run_status`、`agent.text_delta`、`agent.tool_call`;未授权导致的工具失败,`phase` 为 `denied`。断线后,先用全局 `?after=` 补读总线事件,再用 `GET /agent/runs/{id}?afterSeq=<最后收到的 runSeq>` 补读单个 run 的完整事件。SSE 只给控制台(cookie 会话);API Key 调用者用 `GET /events?after=&limit=` 取 JSON 页轮询(见 auth.md「SSE 多进程总线」)。 -控制台 Agent 页直接用这些端点(合同取 `@umt/contracts` 的 `AgentRun` / `AgentRunDetail` / `AgentGoalReport`),`?mock=1` 只是演示开关(本地假数据,不连后端,`?mock=0` 退出),只在开发构建或后端 `pi.provider = "fake"` 时生效;生产构建连真实模型时忽略并清掉记住的开关。页面收到 `agent.*` 事件后按 `afterSeq` 增量补读,`agent.run_status` 到达即刷新 run 快照;SSE 断开时 2.5 秒轮询兜底。 +控制台 Agent 页直接用这些端点(合同取 `@umt/contracts` 的 `AgentRun` / `AgentRunDetail` / `AgentGoalReport`),`?mock=1` 只是演示开关(本地假数据,不连后端,`?mock=0` 退出),只在开发构建或后端默认上游 provider 为 `fake` 时生效;生产构建连真实模型时忽略并清掉记住的开关。页面收到 `agent.*` 事件后按 `afterSeq` 增量补读,`agent.run_status` 到达即刷新 run 快照;SSE 断开时 2.5 秒轮询兜底。 ## runAgent 做什么 1. `drive` 步骤: - - 从设置读 Pi 参数(provider / baseUrl / model / 思考档位 / 上下文预算 / 压缩)和预算。`pi.apiKeySecretRef` 现场解密后传给 SDK,不落日志。 + - 取上游(provider / baseUrl / 模型 / 思考档位来自 `pi_upstream`,凭证由上游的凭证存储在请求时解密注入,不落日志),从设置读上下文预算、压缩和预算。 - 检查今日次数和费用预算(`budget.agentMaxRunsPerDay` / `agentMaxCostUsdPerDay`)。 - 打开 `AGENT_SESSION_DIR/.jsonl`。`pendingOperations>0` 说明上次崩溃:先按幂等键对账,再 `resume()`;否则就 `prompt(goal)`。最后逐条消费追加的消息。 2. `goal-settled` 步骤:只在 execute 模式且会话正常结束时执行。每隔 `agent.goalPollSec` 检查一次,直到本 run 触达的计划、下载和整理记录全部落定,最长等 `agent.goalWaitHours`。等待期间 run 状态为 `waiting`,汇报写的是"已提交≠已入库"。 @@ -125,7 +125,7 @@ curl -X POST localhost:4380/api/v1/automation-grants -H "Authorization: Bearer < | `none` | 不存密钥 | fake、无鉴权的本地端点、Bedrock 凭据链 / Vertex ADC(只有这一种会读进程环境变量与 `~/.aws`、gcloud 文件) | 默认上游是 `settings.pi.defaultUpstreamId`(只能经 `/pi/upstreams` 的 `makeDefault` 改;第一条上游自动成为默认)。 -runAgent 按 `runAgent{upstreamId}` → `agent_run.upstream_id` → 默认上游 → 旧字段(`pi.provider` / `pi.apiKeySecretRef`,仅在没有任何默认上游时兼容,e2e 的 `pi.provider = "fake"` 走这里)选上游, +runAgent 按 `runAgent{upstreamId}` → `agent_run.upstream_id` → 默认上游选上游,都没有时 run 直接失败(「未配置 Agent 上游」)。旧字段 `pi.provider / baseUrl / model / thinkingLevel / apiKeySecretRef` 自 W8-A 起弃用:服务端不读、`PATCH /settings` 丢弃、设置页不展示(`DEPRECATED_SETTINGS_FIELDS`);e2e 直接建一条 `provider=fake` 的上游设为默认。 选中的上游写回 `agent_run.upstream_id / provider / model`。上游停用、未登录(`auth_required`)或登录失效(`expired`)时 run 直接失败并给出原因。 0027 迁移把已保存的旧 `settings.pi`(anthropic / openai-compatible / fake)转成一条上游并设为默认,旧 `apiKeySecretRef` 移交给上游(设置里清空)。 @@ -161,7 +161,7 @@ OAuth 刷新失败 → `expired` + 待处理事项「模型上游 X 登录已失 **条款**:订阅登录借用的是官方 CLI / IDE 的客户端身份(Anthropic 还会伪装成 Claude Code),用于自动化后台服务可能违反提供方条款、有被限流或封号风险; 能力表每个订阅型 provider 都带 `termsNote`,设置页须展示。 -**刷新**:worker 每 10 分钟入队 `refreshPiTokens`,对 30 分钟内过期的 OAuth 上游调 pi 的 `getAuth(minOAuthValidityMs)`。 +**刷新**:定时任务 `pi.token_refresh`(维护组,默认每 10 分钟)入队 `refreshPiTokens`,对 30 分钟内过期的 OAuth 上游调 pi 的 `getAuth(minOAuthValidityMs)`。 pi 在我们的 `CredentialStore.modify` 里双重检查过期时间再刷新;`modify` 在事务里 `select … for update` 锁住上游行, 所以 api(测试端点)与 worker(Agent 运行时的按需刷新、定时任务)同时刷新也只刷新一次,轮换后的 refresh token 原子写回。 OpenRouter 换到的是永久 key,不刷新。 diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index 154a405b..d9edf508 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -1246,14 +1246,14 @@ export const Api = { method: "get", path: "/pi/upstreams", scope: "admin", - summary: "Pi 上游列表(不含密钥)", + summary: "Agent 上游列表(不含密钥)", response: pi.PiUpstreamList, }), get: ep({ method: "get", path: "/pi/upstreams/{id}", scope: "admin", - summary: "Pi 上游详情", + summary: "Agent 上游详情", params: IdParam, response: pi.PiUpstream, }), @@ -1262,7 +1262,7 @@ export const Api = { path: "/pi/upstreams", scope: "admin", auth: "session", - summary: "新建 Pi 上游(apiKey 只写,存 secret 表)", + summary: "新建 Agent 上游(apiKey 只写,存 secret 表)", body: pi.PiUpstreamCreate, response: pi.PiUpstream, }), @@ -1271,7 +1271,7 @@ export const Api = { path: "/pi/upstreams/{id}", scope: "admin", auth: "session", - summary: "修改 Pi 上游(provider 不可改;makeDefault 设默认上游)", + summary: "修改 Agent 上游(provider 不可改;makeDefault 设默认上游)", params: IdParam, body: pi.PiUpstreamUpdate, response: pi.PiUpstream, @@ -1281,7 +1281,7 @@ export const Api = { path: "/pi/upstreams/{id}", scope: "admin", auth: "session", - summary: "删除 Pi 上游(连同凭据;是默认上游时清空默认)", + summary: "删除 Agent 上游(连同凭据;是默认上游时清空默认)", params: IdParam, response: OkResult, }), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 5a5cf2d7..3ebda3ab 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -54,13 +54,14 @@ export type ThinkingLevel = z.infer; * Pi Agent 设置(REQ §10.5)。W7-A 起上游改为 pi_upstream 表(多上游共存,见 contracts/pi.ts 与 /pi/upstreams), * 这里只留默认上游与运行预算(上下文预算/压缩/子代理)。 * - * provider / baseUrl / model / thinkingLevel / apiKeySecretRef 是 v0.1 的旧字段:0027 迁移把已保存的旧值转成一条上游 - * 并设为默认;之后仅在 defaultUpstreamId 为 null 时作为兼容回退(provider = "fake" 的 e2e/演示也走这里)。 + * provider / baseUrl / model / thinkingLevel / apiKeySecretRef 是 v0.1 的旧字段(DEPRECATED_SETTINGS_FIELDS): + * 0027 迁移已把保存的旧值转成一条上游并设为默认。W8-A 起服务端不再读取、PATCH 不再接受(传了会被丢弃), + * 设置页不展示;保留在 schema 里只为兼容已存的 JSON。runAgent 只走上游,没有上游直接失败。 */ export const PiSettings = z.object({ - /** 缺省上游(runAgent 未指定 upstreamId 时使用);null = 回退到旧字段 */ + /** 缺省上游(runAgent 未指定 upstreamId 时使用);null = 未配置,Agent 运行直接失败 */ defaultUpstreamId: Id.nullable(), - /** @deprecated 旧字段,见上 */ + /** @deprecated 旧字段,不再读取,见上 */ provider: z.string().min(1), /** @deprecated */ baseUrl: z.url().nullable(), @@ -226,14 +227,22 @@ export const GlobalSettingsUpdate = z.object({ cache: CacheShape.partial().optional(), organize: OrganizeSettings.partial().optional(), retention: RetentionSettings.partial().optional(), - /** defaultUpstreamId 只能经 /pi/upstreams(makeDefault)修改,保证指向存在的上游 */ - pi: PiSettings.omit({ apiKeySecretRef: true, defaultUpstreamId: true }) + /** + * defaultUpstreamId 只能经 /pi/upstreams(makeDefault)修改,保证指向存在的上游; + * 旧字段(provider / baseUrl / model / thinkingLevel / apiKey)不再接受,传了会被丢弃。 + */ + pi: PiSettings.omit({ + apiKeySecretRef: true, + defaultUpstreamId: true, + provider: true, + baseUrl: true, + model: true, + thinkingLevel: true, + }) .partial() .extend({ compaction: PiSettings.shape.compaction.partial().optional(), subagents: PiSettings.shape.subagents.partial().optional(), - /** 只写:明文上游密钥,服务端存为 secret 并回填 apiKeySecretRef;传 null 表示清除 */ - apiKey: z.string().min(1).max(4096).nullable().optional(), }) .optional(), budget: BudgetSettings.partial().optional(), @@ -250,7 +259,7 @@ export type GlobalSettingsUpdate = z.infer; /** 合并部分更新(纯函数,web 乐观更新与 server 共用) */ export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings { - const { apiKey: _apiKey, compaction, subagents, ...pi } = patch.pi ?? {}; + const { compaction, subagents, ...pi } = patch.pi ?? {}; const { tmdbApiKey: _tmdbApiKey, ...metadata } = patch.metadata ?? {}; return { automation: { ...current.automation, ...patch.automation }, @@ -290,8 +299,17 @@ export const SettingEntry = z.object({ }); export type SettingEntry = z.infer; +/** 已弃用、不归属任何设置分区的旧字段(见 PiSettings 注释) */ +export const DEPRECATED_SETTINGS_FIELDS = [ + "pi.provider", + "pi.baseUrl", + "pi.model", + "pi.thinkingLevel", + "pi.apiKeySecretRef", +] as const; + // ── 设置页分区(docs/design/console-ia.md §3.4) ── -// 只是"展示分区 → 字段"的映射,不改存储 key;每个字段恰好归属一个分区(测试断言)。 +// 只是"展示分区 → 字段"的映射,不改存储 key;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。 // 非 settings 表的对象(实例、站点、API Key、Pi 上游、账户)用 resources 标注,不进 fields。 export const SettingsSectionId = z.enum([ @@ -413,15 +431,10 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [ ], fields: [ "pi.defaultUpstreamId", - "pi.provider", - "pi.baseUrl", - "pi.model", - "pi.thinkingLevel", "pi.contextBudget", "pi.compaction", "pi.subagentsEnabled", "pi.subagents", - "pi.apiKeySecretRef", "budget.agentMaxRunsPerDay", "budget.agentMaxCostUsdPerDay", "agent.autoInvestigate", diff --git a/packages/contracts/test/pi.test.ts b/packages/contracts/test/pi.test.ts index 3e31821b..c85c4cf9 100644 --- a/packages/contracts/test/pi.test.ts +++ b/packages/contracts/test/pi.test.ts @@ -58,13 +58,19 @@ describe("OAuth 请求", () => { }); describe("settings.pi 兼容", () => { - it("缺省 defaultUpstreamId = null;旧字段仍可读写;PATCH 不能直接改 defaultUpstreamId", () => { + it("缺省 defaultUpstreamId = null;PATCH 不能直接改 defaultUpstreamId,旧字段被丢弃", () => { expect(C.DEFAULT_SETTINGS.pi.defaultUpstreamId).toBeNull(); expect(C.GlobalSettings.safeParse(C.DEFAULT_SETTINGS).success).toBe(true); const patch = C.GlobalSettingsUpdate.parse({ - pi: { defaultUpstreamId: "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60", provider: "fake", contextBudget: 64_000 }, + pi: { + defaultUpstreamId: "7d3f7f5e-8a8c-4b8e-9f0a-1b2c3d4e5f60", + provider: "fake", + apiKey: "sk-x", + model: "m", + contextBudget: 64_000, + }, }); - expect(patch.pi).toEqual({ provider: "fake", contextBudget: 64_000 }); + expect(patch.pi).toEqual({ contextBudget: 64_000 }); const current = { ...C.DEFAULT_SETTINGS, pi: { ...C.DEFAULT_SETTINGS.pi, defaultUpstreamId: "keep-me" } }; expect(C.mergeSettings(current as never, patch).pi.defaultUpstreamId).toBe("keep-me"); }); diff --git a/packages/contracts/test/schemas.test.ts b/packages/contracts/test/schemas.test.ts index 65e85130..099d9892 100644 --- a/packages/contracts/test/schemas.test.ts +++ b/packages/contracts/test/schemas.test.ts @@ -157,11 +157,11 @@ describe("关键合同行为", () => { }); it("GlobalSettingsUpdate 部分更新不会注入默认值,mergeSettings 保留现值", () => { - const patch = C.GlobalSettingsUpdate.parse({ automation: { enabled: false }, pi: { apiKey: "sk-x" } }); - expect(patch).toEqual({ automation: { enabled: false }, pi: { apiKey: "sk-x" } }); + const patch = C.GlobalSettingsUpdate.parse({ automation: { enabled: false }, pi: { contextBudget: 64_000 } }); + expect(patch).toEqual({ automation: { enabled: false }, pi: { contextBudget: 64_000 } }); const merged = C.mergeSettings(C.DEFAULT_SETTINGS, patch); expect(merged.automation).toEqual({ enabled: false, readOnlyReconcile: false }); - expect(merged.pi).toEqual(C.DEFAULT_SETTINGS.pi); + expect(merged.pi).toEqual({ ...C.DEFAULT_SETTINGS.pi, contextBudget: 64_000 }); expect(C.GlobalSettings.safeParse(merged).success).toBe(true); }); diff --git a/packages/contracts/test/settings-sections.test.ts b/packages/contracts/test/settings-sections.test.ts index de4e2e50..21b54a1e 100644 --- a/packages/contracts/test/settings-sections.test.ts +++ b/packages/contracts/test/settings-sections.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_SETTINGS, + DEPRECATED_SETTINGS_FIELDS, SETTINGS_SECTIONS, SettingsSectionId, settingsSectionForLegacyTab, @@ -11,8 +12,11 @@ describe("SETTINGS_SECTIONS", () => { expect(SETTINGS_SECTIONS.map((s) => s.id)).toEqual(SettingsSectionId.options); }); - it("8 个存储 key 的每个字段恰好归属一个分区", () => { - const all = Object.entries(DEFAULT_SETTINGS).flatMap(([key, v]) => Object.keys(v).map((f) => `${key}.${f}`)); + it("8 个存储 key 的每个字段(弃用字段除外)恰好归属一个分区", () => { + const deprecated = new Set(DEPRECATED_SETTINGS_FIELDS); + const all = Object.entries(DEFAULT_SETTINGS) + .flatMap(([key, v]) => Object.keys(v).map((f) => `${key}.${f}`)) + .filter((f) => !deprecated.has(f)); const owned = SETTINGS_SECTIONS.flatMap((s) => s.fields); expect(new Set(owned).size).toBe(owned.length); expect([...owned].sort()).toEqual([...all].sort());