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 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 13:45:40 +08:00
1 parent fb7727cf66
commit 3e37ac8ae7
15 files changed
+122 -115

No files matched your search

+5 -5
View File
@@ -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,
}),
+28 -15
View File
@@ -54,13 +54,14 @@ export type ThinkingLevel = z.infer<typeof ThinkingLevel>;
* 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<typeof GlobalSettingsUpdate>;
/** 合并部分更新(纯函数,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<typeof SettingEntry>;
/** 已弃用、不归属任何设置分区的旧字段(见 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",
+9 -3
View File
@@ -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");
});
+3 -3
View File
@@ -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);
});
@@ -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<string>(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());