feat(contracts,db): Pi upstream contracts and pi_upstream table

- contracts/pi.ts: 41 builtin provider ids + openai-compatible + fake, auth kinds,
  upstream row/view/create/update, provider capability table (oauth modes, termsNote),
  test/models/oauth start/complete schemas
- settings.pi gains defaultUpstreamId; legacy single-upstream fields kept for fallback
- api.ts: /pi/providers, /pi/upstreams CRUD, test, models, oauth start/complete/logout
  (writes and login are console-session only)
- migration 0027: pi_upstream, agent_run.upstream_id, one-time conversion of saved
  legacy settings.pi into a default upstream

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 02:23:09 +08:00
1 parent 39a001c099
commit b9dbd54e37
8 files changed
+665 -4

No files matched your search

+103
View File
@@ -12,6 +12,7 @@ import * as identity from "./identity.ts";
import * as instances from "./instances.ts";
import * as ops from "./ops.ts";
import * as organization from "./organization.ts";
import * as pi from "./pi.ts";
import * as policy from "./policy.ts";
import { GlobalSettings, GlobalSettingsUpdate } from "./settings.ts";
import * as sources from "./sources.ts";
@@ -1230,6 +1231,108 @@ export const Api = {
}),
},
// ── Pi 模型上游(W7-A):多上游共存 + 订阅登录;写操作与登录只接受控制台会话 ──
pi: {
providers: ep({
method: "get",
path: "/pi/providers",
scope: "admin",
summary: "Pi provider 能力表(认证方式、订阅登录方式与条款提示、baseUrl 覆盖、env 字段、静态模型表)",
query: pi.PiProvidersQuery,
response: pi.PiProvidersResponse,
}),
list: ep({
method: "get",
path: "/pi/upstreams",
scope: "admin",
summary: "Pi 上游列表(不含密钥)",
response: pi.PiUpstreamList,
}),
get: ep({
method: "get",
path: "/pi/upstreams/{id}",
scope: "admin",
summary: "Pi 上游详情",
params: IdParam,
response: pi.PiUpstream,
}),
create: ep({
method: "post",
path: "/pi/upstreams",
scope: "admin",
auth: "session",
summary: "新建 Pi 上游(apiKey 只写,存 secret 表)",
body: pi.PiUpstreamCreate,
response: pi.PiUpstream,
}),
update: ep({
method: "patch",
path: "/pi/upstreams/{id}",
scope: "admin",
auth: "session",
summary: "修改 Pi 上游(provider 不可改;makeDefault 设默认上游)",
params: IdParam,
body: pi.PiUpstreamUpdate,
response: pi.PiUpstream,
}),
remove: ep({
method: "delete",
path: "/pi/upstreams/{id}",
scope: "admin",
auth: "session",
summary: "删除 Pi 上游(连同凭据;是默认上游时清空默认)",
params: IdParam,
response: OkResult,
}),
test: ep({
method: "post",
path: "/pi/upstreams/{id}/test",
scope: "admin",
auth: "session",
summary: "测试上游:默认只解析凭据与校验模型(不产生费用);mode=completion 发一次最小补全",
params: IdParam,
body: pi.PiUpstreamTestRequest,
response: pi.PiUpstreamTestResult,
}),
models: ep({
method: "get",
path: "/pi/upstreams/{id}/models",
scope: "admin",
summary: "上游可用模型(静态目录;Radius 先动态刷新)",
params: IdParam,
response: pi.PiUpstreamModels,
}),
oauthStart: ep({
method: "post",
path: "/pi/upstreams/{id}/oauth/start",
scope: "admin",
auth: "session",
summary: "开始订阅登录:返回授权链接(browser)或验证码(device)",
params: IdParam,
body: pi.PiOAuthStartRequest,
response: pi.PiOAuthStartResult,
}),
oauthComplete: ep({
method: "post",
path: "/pi/upstreams/{id}/oauth/complete",
scope: "admin",
auth: "session",
summary: "完成订阅登录:browser 粘回地址栏 URL;device 服务端轮询(本次最多等 waitMs,未完成返回 pending)",
params: IdParam,
body: pi.PiOAuthCompleteRequest,
response: pi.PiOAuthCompleteResult,
}),
oauthLogout: ep({
method: "post",
path: "/pi/upstreams/{id}/oauth/logout",
scope: "admin",
auth: "session",
summary: "退出订阅登录:删除本地 token(不会撤销提供方侧授权)",
params: IdParam,
response: pi.PiUpstream,
}),
},
// ── 登录门禁 ──
/** /auth/setup、/auth/login、/auth/session 免鉴权;改密与 TOTP 只接受控制台 cookie 会话 */
auth: {
+1
View File
@@ -14,6 +14,7 @@ export * from "./identity.ts";
export * from "./instances.ts";
export * from "./ops.ts";
export * from "./organization.ts";
export * from "./pi.ts";
export * from "./policy.ts";
export * from "./redact.ts";
export * from "./settings.ts";
+362
View File
@@ -0,0 +1,362 @@
import { z } from "zod";
import { Id, Timestamp } from "./common.ts";
// Pi 模型上游(W7-A):一个上游 = 一个 pi-ai provider 的一份凭据(API Key / 订阅登录 token / 环境凭据)。
// 同一 provider 可以建多个上游(例如两个 Codex 账号);Agent 运行按上游 id 取凭据。
// 研究结论与映射见 docs/design/pi-providers.md;provider 列表锁定 pi-ai 0.87.1 的 builtinProviders()。
/** pi-ai 0.87.1 内置的 41 个 provider id */
export const PI_BUILTIN_PROVIDER_IDS = [
"amazon-bedrock",
"ant-ling",
"anthropic",
"azure-openai-responses",
"baseten",
"cerebras",
"cloudflare-ai-gateway",
"cloudflare-workers-ai",
"deepseek",
"fireworks",
"github-copilot",
"google",
"google-vertex",
"groq",
"huggingface",
"kimi-coding",
"meta",
"minimax",
"minimax-cn",
"mistral",
"moonshotai",
"moonshotai-cn",
"nvidia",
"openai",
"openai-codex",
"opencode",
"opencode-go",
"openrouter",
"qwen-token-plan",
"qwen-token-plan-cn",
"qwen-token-plan-individual",
"radius",
"together",
"vercel-ai-gateway",
"xai",
"xiaomi",
"xiaomi-token-plan-ams",
"xiaomi-token-plan-cn",
"xiaomi-token-plan-sgp",
"zai",
"zai-coding-cn",
] as const;
/**
* 上游可选的 provider:41 个内置 + openai-compatible(自定义 OpenAI 兼容端点,必须填 baseUrl 与模型)
* + fake(脚本化假模型,不联网,演示与测试用)。
*/
export const PiProviderId = z.enum([...PI_BUILTIN_PROVIDER_IDS, "openai-compatible", "fake"]);
export type PiProviderId = z.infer<typeof PiProviderId>;
/** api_key:存一把 key(可附带 env);oauth:订阅登录 token 包;none:不存密钥(fake、无鉴权本地端点、云环境凭据链) */
export const PiAuthKind = z.enum(["api_key", "oauth", "none"]);
export type PiAuthKind = z.infer<typeof PiAuthKind>;
/**
* 上游状态(服务端维护,只读):
* ok = 凭据就绪;auth_required = 需要填 key 或登录;expired = OAuth 刷新失败,需要重新登录;error = 最近一次测试/调用失败
*/
export const PiUpstreamStatus = z.enum(["ok", "auth_required", "expired", "error"]);
export type PiUpstreamStatus = z.infer<typeof PiUpstreamStatus>;
/** pi 的思考档位(比 settings.ThinkingLevel 多一个 max) */
export const PiThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
export type PiThinkingLevel = z.infer<typeof PiThinkingLevel>;
/** 订阅登录方式:browser = 打开授权链接后把地址栏 URL 粘回来;device = 在任意设备输入验证码,服务端轮询 */
export const PiOAuthMode = z.enum(["browser", "device"]);
export type PiOAuthMode = z.infer<typeof PiOAuthMode>;
/** 自定义端点可选的 API 形状 */
export const PiCustomApi = z.enum([
"openai-completions",
"openai-responses",
"anthropic-messages",
"google-generative-ai",
]);
export type PiCustomApi = z.infer<typeof PiCustomApi>;
/** provider 读取的配置名(AZURE_OPENAI_BASE_URL 这类大写名;其它名字 provider 不会读) */
const EnvName = z.string().min(1).max(64);
/**
* provider 专属的非密字段:
* env = provider 需要的配置值(Cloudflare ACCOUNT_ID/GATEWAY_ID、Vertex project/location、Azure resource 等,见能力表 envFields);
* api/compat/headers 只对 openai-compatible 生效。
*/
export const PiUpstreamExtra = z
.object({
env: z.record(EnvName, z.string().max(2048)).optional(),
api: PiCustomApi.optional(),
compat: z.record(z.string(), z.unknown()).optional(),
headers: z.record(z.string().min(1).max(128), z.string().max(2048)).optional(),
/** 自定义模型的上下文窗口(openai-compatible) */
contextWindow: z.number().int().min(1024).max(10_000_000).optional(),
note: z.string().max(500).optional(),
})
.strict();
export type PiUpstreamExtra = z.infer<typeof PiUpstreamExtra>;
// ── 能力表(GET /pi/providers) ──
export const PiModelInfo = z.object({
id: z.string(),
name: z.string(),
api: z.string(),
reasoning: z.boolean(),
/** getSupportedThinkingLevels(model):该模型能用的思考档位 */
thinkingLevels: z.array(PiThinkingLevel),
contextWindow: z.number().int().nonnegative(),
maxTokens: z.number().int().nonnegative(),
input: z.array(z.enum(["text", "image"])),
/** static = 随包目录;dynamic = 上游拉取(仅 Radius) */
source: z.enum(["static", "dynamic", "custom"]),
});
export type PiModelInfo = z.infer<typeof PiModelInfo>;
export const PiEnvField = z.object({
name: EnvName,
label: z.string(),
required: z.boolean(),
hint: z.string().nullable(),
});
export type PiEnvField = z.infer<typeof PiEnvField>;
export const PiProviderGroup = z.enum(["subscription", "api_key", "cloud", "custom", "test"]);
export type PiProviderGroup = z.infer<typeof PiProviderGroup>;
export const PiProviderInfo = z.object({
id: PiProviderId,
name: z.string(),
/** UI 分组:订阅登录 / API Key / 云凭据 / 自定义兼容端点 / 测试 */
group: PiProviderGroup,
/** 可选的认证方式;第一个是推荐项 */
authKinds: z.array(PiAuthKind).min(1),
/** 订阅登录;null = 不支持 */
oauth: z
.object({
name: z.string(),
/** 是否借用个人订阅(Claude Pro/Max、ChatGPT Plus/Pro 等) */
isSubscription: z.boolean(),
/** 可用的登录方式,第一个是推荐项(有 device 的优先 device) */
modes: z.array(PiOAuthMode).min(1),
/** 条款风险提示(一句事实);非订阅型为 null */
termsNote: z.string().nullable(),
})
.nullable(),
/** 订阅登录 provider 的条款风险提示(同 oauth.termsNote,便于列表直接展示) */
termsNote: z.string().nullable(),
/** 是否允许覆盖 baseUrl(OAuth 型与按 token 计算域名的不允许) */
baseUrlOverridable: z.boolean(),
defaultBaseUrl: z.string().nullable(),
/** 需要额外填写的 env 字段 */
envFields: z.array(PiEnvField),
apis: z.array(z.string()),
/** 任一模型支持思考 */
supportsThinking: z.boolean(),
/** 模型表可动态刷新(Radius) */
dynamicModels: z.boolean(),
modelCount: z.number().int().nonnegative(),
/** 静态模型表;GET /pi/providers?models=false 时为空数组 */
models: z.array(PiModelInfo),
note: z.string().nullable(),
});
export type PiProviderInfo = z.infer<typeof PiProviderInfo>;
export const PiProvidersQuery = z.object({
/** false = 不带静态模型表(只要能力与计数) */
models: z.enum(["true", "false"]).optional(),
});
export const PiProvidersResponse = z.object({
/** pi-ai 版本与随包模型目录生成时间 */
piVersion: z.string(),
catalogGeneratedAt: Timestamp.nullable(),
providers: z.array(PiProviderInfo),
});
export type PiProvidersResponse = z.infer<typeof PiProvidersResponse>;
// ── 上游 ──
/** pi_upstream 表行(secret_ref 不出 API) */
export const PiUpstreamRow = z.object({
id: Id,
name: z.string().min(1).max(64),
provider: PiProviderId,
authKind: PiAuthKind,
baseUrl: z.url().nullable(),
defaultModel: z.string().min(1).max(200).nullable(),
thinkingLevel: PiThinkingLevel,
extra: PiUpstreamExtra,
enabled: z.boolean(),
status: PiUpstreamStatus,
lastError: z.string().nullable(),
lastVerifiedAt: Timestamp.nullable(),
/** OAuth access token 过期时间(刷新任务据此挑选;永久 key 为 null) */
oauthExpiresAt: Timestamp.nullable(),
createdAt: Timestamp,
updatedAt: Timestamp,
});
export type PiUpstreamRow = z.infer<typeof PiUpstreamRow>;
/** API 视图:不含任何密钥,只给是否已配置与掩码 */
export const PiUpstream = PiUpstreamRow.extend({
/** 已存 key / 已登录 */
hasCredential: z.boolean(),
/** API Key 掩码(前 4 + 后 4);OAuth / none 为 null */
credentialMask: z.string().nullable(),
/** OAuth 账号信息(Codex accountId、Copilot 域名等,非密);无则 null */
account: z.string().nullable(),
/** 是否为 settings.pi.defaultUpstreamId */
isDefault: z.boolean(),
});
export type PiUpstream = z.infer<typeof PiUpstream>;
export const PiUpstreamList = z.object({
items: z.array(PiUpstream),
defaultUpstreamId: Id.nullable(),
});
export type PiUpstreamList = z.infer<typeof PiUpstreamList>;
const ApiKeyInput = z.string().min(1).max(8192);
/** 新建上游。apiKey 只写(存 secret 表);oauth 型建好后走 /oauth/start 登录 */
export const PiUpstreamCreate = z
.object({
name: z.string().trim().min(1).max(64),
provider: PiProviderId,
authKind: PiAuthKind,
apiKey: ApiKeyInput.optional(),
baseUrl: z.url().nullable().optional(),
defaultModel: z.string().trim().min(1).max(200).nullable().optional(),
thinkingLevel: PiThinkingLevel.optional(),
extra: PiUpstreamExtra.optional(),
enabled: z.boolean().optional(),
/** 同时设为默认上游(settings.pi.defaultUpstreamId);没有默认上游时自动设为默认 */
makeDefault: z.boolean().optional(),
})
.superRefine((v, ctx) => {
if (v.authKind !== "api_key" && v.apiKey !== undefined)
ctx.addIssue({ code: "custom", path: ["apiKey"], message: "只有 api_key 型上游可以填 apiKey" });
if (v.authKind === "oauth" && v.baseUrl)
ctx.addIssue({ code: "custom", path: ["baseUrl"], message: "订阅登录型上游不能覆盖 baseUrl" });
if (v.provider === "openai-compatible") {
if (!v.baseUrl) ctx.addIssue({ code: "custom", path: ["baseUrl"], message: "自定义兼容端点必须填 baseUrl" });
if (!v.defaultModel)
ctx.addIssue({ code: "custom", path: ["defaultModel"], message: "自定义兼容端点必须填模型 id" });
}
});
export type PiUpstreamCreate = z.infer<typeof PiUpstreamCreate>;
/** 修改上游:provider 不可改;apiKey 传 null 清除;authKind 变化时旧凭据一并删除 */
export const PiUpstreamUpdate = z.object({
name: z.string().trim().min(1).max(64).optional(),
authKind: PiAuthKind.optional(),
apiKey: ApiKeyInput.nullable().optional(),
baseUrl: z.url().nullable().optional(),
defaultModel: z.string().trim().min(1).max(200).nullable().optional(),
thinkingLevel: PiThinkingLevel.optional(),
extra: PiUpstreamExtra.optional(),
enabled: z.boolean().optional(),
/** true = 设为默认上游;false = 若它是默认则取消默认 */
makeDefault: z.boolean().optional(),
});
export type PiUpstreamUpdate = z.infer<typeof PiUpstreamUpdate>;
// ── 测试 / 模型 ──
export const PiUpstreamTestRequest = z.object({
/**
* auth(默认)= 只解析凭据(OAuth 必要时刷新)+ 校验模型在目录中,不产生费用;
* completion = 额外发一次最小补全(1 个输出 token,会产生极少费用)
*/
mode: z.enum(["auth", "completion"]).optional(),
model: z.string().min(1).max(200).optional(),
});
export type PiUpstreamTestRequest = z.infer<typeof PiUpstreamTestRequest>;
export const PiTestErrorKind = z.enum(["auth", "oauth", "model", "network", "config", "other"]);
export type PiTestErrorKind = z.infer<typeof PiTestErrorKind>;
export const PiUpstreamTestResult = z.object({
ok: z.boolean(),
mode: z.enum(["auth", "completion"]),
latencyMs: z.number().int().nonnegative(),
/** 凭据来源说明(stored credential / OAuth / AWS_PROFILE …) */
source: z.string().nullable(),
model: z.string().nullable(),
errorKind: PiTestErrorKind.nullable(),
message: z.string().nullable(),
upstream: PiUpstream,
});
export type PiUpstreamTestResult = z.infer<typeof PiUpstreamTestResult>;
export const PiUpstreamModels = z.object({
items: z.array(PiModelInfo),
/** 动态模型表是否已从上游刷新成功(静态 provider 恒为 false) */
refreshed: z.boolean(),
/** 动态刷新失败原因(仍返回静态表) */
refreshError: z.string().nullable(),
});
export type PiUpstreamModels = z.infer<typeof PiUpstreamModels>;
// ── 订阅登录状态机 ──
export const PiOAuthStartRequest = z.object({
/** 缺省取能力表 oauth.modes[0] */
mode: PiOAuthMode.optional(),
/** GitHub Copilot:GitHub Enterprise 域名(空 = github.com) */
enterpriseDomain: z.string().max(253).optional(),
});
export type PiOAuthStartRequest = z.infer<typeof PiOAuthStartRequest>;
/**
* 登录会话状态:
* pending = 等用户去授权(browser 等粘贴,device 服务端轮询中);succeeded = 已写入凭据;
* failed = 流程报错(message 可读);expired = 会话超时;cancelled = 被新会话/登出取消
*/
export const PiOAuthState = z.enum(["pending", "succeeded", "failed", "expired", "cancelled"]);
export type PiOAuthState = z.infer<typeof PiOAuthState>;
export const PiOAuthStartResult = z.object({
sessionId: z.string(),
mode: PiOAuthMode,
state: PiOAuthState,
/** browser:在自己的浏览器打开;授权后跳到 localhost 打不开是预期的,复制地址栏完整 URL 粘回 */
authorizeUrl: z.string().nullable(),
/** device:在 verificationUrl 输入 userCode */
userCode: z.string().nullable(),
verificationUrl: z.string().nullable(),
/** 给用户看的提示(provider 原话) */
instructions: z.string().nullable(),
expiresAt: Timestamp,
message: z.string().nullable(),
});
export type PiOAuthStartResult = z.infer<typeof PiOAuthStartResult>;
export const PiOAuthCompleteRequest = z.object({
sessionId: z.string().min(1).max(64),
/** browser:地址栏完整 URL(或 code#state / 裸 code);device 不填 */
pastedUrlOrCode: z.string().trim().min(1).max(8192).optional(),
/** 本次请求最多等待多久(毫秒,默认 25s,上限 60s);超时返回 state=pending,前端再调一次 */
waitMs: z.number().int().min(0).max(60_000).optional(),
});
export type PiOAuthCompleteRequest = z.infer<typeof PiOAuthCompleteRequest>;
export const PiOAuthCompleteResult = z.object({
sessionId: z.string(),
state: PiOAuthState,
message: z.string().nullable(),
/** 最近的进度提示(Exchanging authorization code… 等) */
progress: z.array(z.string()),
upstream: PiUpstream,
});
export type PiOAuthCompleteResult = z.infer<typeof PiOAuthCompleteResult>;
+18 -3
View File
@@ -50,11 +50,23 @@ export type RetentionSettings = z.infer<typeof RetentionSettings>;
export const ThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]);
export type ThinkingLevel = z.infer<typeof ThinkingLevel>;
/** Pi Agent 设置(REQ §10.5):上游/模型/思考档位/上下文预算/压缩/子代理;密钥只存 secret 引用 */
/**
* 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/演示也走这里)。
*/
export const PiSettings = z.object({
/** 缺省上游(runAgent 未指定 upstreamId 时使用);null = 回退到旧字段 */
defaultUpstreamId: Id.nullable(),
/** @deprecated 旧字段,见上 */
provider: z.string().min(1),
/** @deprecated */
baseUrl: z.url().nullable(),
/** @deprecated */
model: z.string().min(1).nullable(),
/** @deprecated 上游级 thinkingLevel 见 pi_upstream */
thinkingLevel: ThinkingLevel,
/** 上下文预算(token) */
contextBudget: z.number().int().min(4096).max(2_000_000),
@@ -64,13 +76,14 @@ export const PiSettings = z.object({
thresholdRatio: z.number().min(0.3).max(0.95),
}),
subagentsEnabled: z.boolean(),
/** @deprecated */
apiKeySecretRef: Id.nullable(),
});
export type PiSettings = z.infer<typeof PiSettings>;
/**
* Agent 运行参数(REQ §10.5、§10.7):单次运行预算、后台自动调查、fake 演示脚本。
* 上游/模型/思考档位在 pi 分组;pi.provider = "fake" 时使用脚本化假模型(不联网,演示与 e2e 用)。
* 上游/模型/思考档位在 pi_upstream(见 contracts/pi.ts);provider = "fake" 的上游使用脚本化假模型(不联网,演示与 e2e 用)。
*/
export const AgentRuntimeSettings = z.object({
/** 识别歧义 / 下载失败诊断等待处理事项创建时,自动发起有界调查 */
@@ -161,6 +174,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
organize: { defaultMode: "copy", allowCrossVolumeMove: true },
retention: { seedRetentionDays: 7, logRetentionDays: 180 },
pi: {
defaultUpstreamId: null,
provider: "anthropic",
baseUrl: null,
model: null,
@@ -201,7 +215,8 @@ export const GlobalSettingsUpdate = z.object({
cache: CacheShape.partial().optional(),
organize: OrganizeSettings.partial().optional(),
retention: RetentionSettings.partial().optional(),
pi: PiSettings.omit({ apiKeySecretRef: true })
/** defaultUpstreamId 只能经 /pi/upstreams(makeDefault)修改,保证指向存在的上游 */
pi: PiSettings.omit({ apiKeySecretRef: true, defaultUpstreamId: true })
.partial()
.extend({
compaction: PiSettings.shape.compaction.partial().optional(),
+7 -1
View File
@@ -39,7 +39,8 @@ type Obj = z.ZodObject;
const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }> = {
AcquisitionPlan: { row: C.AcquisitionPlan },
AcquisitionSlot: { row: C.AcquisitionSlot, apiOnly: ["currentAsset"] },
AgentRun: { row: C.AgentRun },
// upstream_id 只在服务端使用(runAgent 选上游),不在 AgentRun 视图里
AgentRun: { row: C.AgentRun, dbOnly: ["upstreamId"] },
AgentRunEvent: { row: C.AgentRunEventRow },
AgentRunMessage: { row: C.AgentRunMessage },
CapabilityInvocation: { row: C.CapabilityInvocation },
@@ -99,6 +100,8 @@ const ROWS: Record<string, { row: Obj; dbOnly?: string[]; apiOnly?: string[] }>
SlotDeferral: { row: C.SlotDeferral },
SlotUnit: { row: C.SlotUnit },
SubscriptionHistory: { row: C.SubscriptionHistory },
// W7-A Pi 上游(0027):secret_ref 不出 API
PiUpstream: { row: C.PiUpstreamRow, dbOnly: ["secretRef"] },
};
/** 引擎自有表(@umt/tasks 内部使用,不经 API 暴露),不要求 Row schema */
@@ -191,6 +194,9 @@ const ENUMS: Record<string, z.ZodEnum> = {
ReconcileRunMode: C.ReconcileMode,
ReconcileRunStatus: C.ReconcileStatus,
SecretPurpose: C.SecretPurpose,
PiUpstreamAuthKind: C.PiAuthKind,
PiUpstreamStatus: C.PiUpstreamStatus,
PiUpstreamThinkingLevel: C.PiThinkingLevel,
ServerMappingItemType: C.ServerItemType,
ServerMappingMatchStatus: C.ServerMatchStatus,
SiteAdapter: C.SiteAdapter,
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import * as C from "../src/index.ts";
// W7-A Pi 上游合同:跨字段约束的正反例、设置兼容。
describe("PiUpstreamCreate", () => {
const ok = (v: unknown) => C.PiUpstreamCreate.safeParse(v).success;
it("正例:api_key / oauth / none / 自定义端点", () => {
expect(ok({ name: "OpenAI 主号", provider: "openai", authKind: "api_key", apiKey: "sk-1" })).toBe(true);
expect(ok({ name: "Codex", provider: "openai-codex", authKind: "oauth", thinkingLevel: "max" })).toBe(true);
expect(
ok({
name: "Bedrock",
provider: "amazon-bedrock",
authKind: "none",
extra: { env: { AWS_REGION: "us-east-1" } },
}),
).toBe(true);
expect(
ok({
name: "本地 vLLM",
provider: "openai-compatible",
authKind: "none",
baseUrl: "http://llm.local/v1",
defaultModel: "qwen3",
extra: { api: "openai-completions", compat: { supportsDeveloperRole: false } },
}),
).toBe(true);
expect(ok({ name: "fake", provider: "fake", authKind: "none" })).toBe(true);
});
it("反例:未知 provider、oauth 带 apiKey / baseUrl、自定义端点缺 baseUrl 或模型、extra 多余字段、名字为空", () => {
expect(ok({ name: "x", provider: "google-gemini-cli", authKind: "oauth" })).toBe(false);
expect(ok({ name: "x", provider: "anthropic", authKind: "oauth", apiKey: "sk" })).toBe(false);
expect(ok({ name: "x", provider: "anthropic", authKind: "oauth", baseUrl: "https://p.local" })).toBe(false);
expect(ok({ name: "x", provider: "openai-compatible", authKind: "api_key", defaultModel: "m" })).toBe(false);
expect(ok({ name: "x", provider: "openai-compatible", authKind: "api_key", baseUrl: "http://a/v1" })).toBe(false);
expect(ok({ name: "x", provider: "openai", authKind: "api_key", extra: { secret: "sk" } })).toBe(false);
expect(ok({ name: " ", provider: "openai", authKind: "api_key" })).toBe(false);
expect(ok({ name: "x", provider: "openai", authKind: "token" })).toBe(false);
});
it("provider 枚举 = 41 个内置 + openai-compatible + fake", () => {
expect(C.PI_BUILTIN_PROVIDER_IDS).toHaveLength(41);
expect(C.PiProviderId.options).toHaveLength(43);
});
});
describe("OAuth 请求", () => {
it("start/complete 的边界", () => {
expect(C.PiOAuthStartRequest.safeParse({}).success).toBe(true);
expect(C.PiOAuthStartRequest.safeParse({ mode: "popup" }).success).toBe(false);
expect(C.PiOAuthCompleteRequest.safeParse({ sessionId: "s" }).success).toBe(true);
expect(C.PiOAuthCompleteRequest.safeParse({ sessionId: "s", waitMs: 120_000 }).success).toBe(false);
expect(C.PiOAuthCompleteRequest.safeParse({ pastedUrlOrCode: "x" }).success).toBe(false);
});
});
describe("settings.pi 兼容", () => {
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 },
});
expect(patch.pi).toEqual({ provider: "fake", 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");
});
});
@@ -0,0 +1,76 @@
import { type Kysely, sql } from "kysely";
import { entityTable, execDdl, oneOf } from "../src/migration-helpers.ts";
// W7-A Pi 上游:pi_upstream(同一 provider 可多个账号并存),凭据(API Key 或 OAuth token 包 JSON)在 secret 表,
// 这里只存 secret_ref。agent_run.upstream_id 记录本次运行用的上游(runAgent 可按 upstreamId 覆盖默认上游)。
// 一次性迁移:旧 settings.pi(v0.1 单上游字段)若已保存过,转成一条上游并设为默认;旧 apiKeySecretRef 的所有权
// 移交给新上游(settings 里清空,避免旧设置页清除密钥时误删上游凭据)。
const AUTH_KINDS = ["api_key", "oauth", "none"] as const;
const STATUSES = ["ok", "auth_required", "expired", "error"] as const;
const THINKING = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
export async function up(db: Kysely<any>): Promise<void> {
await execDdl(
db,
`
${entityTable(
"pi_upstream",
`
name text not null,
provider text not null,
auth_kind text not null ${oneOf("auth_kind", AUTH_KINDS)},
secret_ref uuid references secret (id) on delete set null,
base_url text,
default_model text,
thinking_level text not null default 'medium' ${oneOf("thinking_level", THINKING)},
extra jsonb not null default '{}'::jsonb,
enabled boolean not null default true,
status text not null default 'auth_required' ${oneOf("status", STATUSES)},
last_error text,
last_verified_at timestamptz,
oauth_expires_at timestamptz,
constraint pi_upstream_name_key unique (name)`,
)}
create index pi_upstream_oauth_expiry_idx on pi_upstream (oauth_expires_at)
where auth_kind = 'oauth' and secret_ref is not null;
alter table agent_run add column upstream_id uuid references pi_upstream (id) on delete set null;
`,
);
// 旧 settings.pi → 一条上游
const row = await sql<{ value: Record<string, unknown> }>`select value from settings where key = 'pi'`.execute(db);
const pi = row.rows[0]?.value;
if (!pi || typeof pi !== "object" || typeof pi.provider !== "string" || pi.defaultUpstreamId) return;
const legacy = pi.provider;
const provider = legacy === "openai-compatible" || legacy === "fake" || legacy === "anthropic" ? legacy : null;
if (!provider) return;
const secretRef = typeof pi.apiKeySecretRef === "string" ? pi.apiKeySecretRef : null;
const authKind = provider === "fake" ? "none" : "api_key";
const status = provider === "fake" || secretRef ? "ok" : "auth_required";
const thinking = THINKING.includes(pi.thinkingLevel as never) ? (pi.thinkingLevel as string) : "medium";
const inserted = await sql<{ id: string }>`
insert into pi_upstream (name, provider, auth_kind, secret_ref, base_url, default_model, thinking_level, status)
values (${`默认上游(${provider})`}, ${provider}, ${authKind},
(select id from secret where id = ${secretRef}::uuid), ${typeof pi.baseUrl === "string" ? pi.baseUrl : null},
${typeof pi.model === "string" ? pi.model : provider === "fake" ? "fake-model" : null}, ${thinking}, ${status})
returning id`.execute(db);
const id = inserted.rows[0]!.id;
await sql`update settings set value = value || ${JSON.stringify({ defaultUpstreamId: id, apiKeySecretRef: null })}::jsonb,
updated_at = now() where key = 'pi'`.execute(db);
}
// biome-ignore lint/suspicious/noExplicitAny: 同上
export async function down(db: Kysely<any>): Promise<void> {
// 默认上游的密钥交还给旧字段,再删表
await sql`update settings s set value = (s.value - 'defaultUpstreamId') || jsonb_build_object('apiKeySecretRef', u.secret_ref)
from pi_upstream u
where s.key = 'pi' and u.id::text = s.value->>'defaultUpstreamId' and u.auth_kind = 'api_key'`.execute(db);
await sql`update settings set value = value - 'defaultUpstreamId' where key = 'pi'`.execute(db);
await execDdl(
db,
"alter table agent_run drop column if exists upstream_id;\ndrop table if exists pi_upstream cascade;",
);
}
+27
View File
@@ -182,6 +182,9 @@ export type PathBindingBindingKind = "entity" | "season";
export type PathBindingStatus = "active" | "conflict" | "superseded";
export type PathMappingViewKind = "downloader" | "media_server";
export type PathMappingVerifyStatus = "unverified" | "ok" | "failed";
export type PiUpstreamAuthKind = "api_key" | "oauth" | "none";
export type PiUpstreamThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export type PiUpstreamStatus = "ok" | "auth_required" | "expired" | "error";
export type PolicyRevisionKind = "acquisition" | "preference" | "retention" | "cleanup" | "instance" | "automation";
export type PolicyRevisionStatus = "draft" | "published" | "superseded" | "rolled_back" | "archived";
export type PreferenceTagKind = "soft" | "hard" | "version" | "target";
@@ -340,6 +343,7 @@ export interface AgentRunTable {
export interface AgentRunEventTable {
run_id: string;
seq: number;
upstream_id: string | null;
type: AgentRunEventType;
at: GeneratedTimestamptz;
payload: Generated<Json>;
@@ -996,6 +1000,25 @@ export interface PolicyRevisionTable {
policy_key: string;
revision: number;
kind: PolicyRevisionKind;
export interface PiUpstreamTable {
id: Generated<string>;
name: string;
provider: string;
auth_kind: PiUpstreamAuthKind;
secret_ref: string | null;
base_url: string | null;
default_model: string | null;
thinking_level: Generated<PiUpstreamThinkingLevel>;
extra: Generated<Json>;
enabled: Generated<boolean>;
status: Generated<PiUpstreamStatus>;
last_error: string | null;
last_verified_at: Timestamptz | null;
oauth_expires_at: Timestamptz | null;
created_at: GeneratedTimestamptz;
updated_at: GeneratedTimestamptz;
}
status: Generated<PolicyRevisionStatus>;
title: string;
summary: string | null;
@@ -1446,6 +1469,7 @@ export type AgentRunMessageRow = Selectable<AgentRunMessageTable>;
export type NewAgentRunMessage = Insertable<AgentRunMessageTable>;
export type AgentRunMessageUpdate = Updateable<AgentRunMessageTable>;
export type ApiKeyRow = Selectable<ApiKeyTable>;
pi_upstream: PiUpstreamTable;
export type NewApiKey = Insertable<ApiKeyTable>;
export type ApiKeyUpdate = Updateable<ApiKeyTable>;
export type ApprovalRow = Selectable<ApprovalTable>;
@@ -1599,6 +1623,9 @@ export type SubscriptionRow = Selectable<SubscriptionTable>;
export type NewSubscription = Insertable<SubscriptionTable>;
export type SubscriptionUpdate = Updateable<SubscriptionTable>;
export type SubscriptionHistoryRow = Selectable<SubscriptionHistoryTable>;
export type PiUpstreamRow = Selectable<PiUpstreamTable>;
export type NewPiUpstream = Insertable<PiUpstreamTable>;
export type PiUpstreamUpdate = Updateable<PiUpstreamTable>;
export type NewSubscriptionHistory = Insertable<SubscriptionHistoryTable>;
export type SubscriptionHistoryUpdate = Updateable<SubscriptionHistoryTable>;
export type TaskRow = Selectable<TaskTable>;