From b9dbd54e37de5871a65de80d242d87cadf02f5ec Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 02:23:09 +0800 Subject: [PATCH] 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 --- packages/contracts/src/api.ts | 103 ++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/pi.ts | 362 ++++++++++++++++++++ packages/contracts/src/settings.ts | 21 +- packages/contracts/test/db-sync.test.ts | 8 +- packages/contracts/test/pi.test.ts | 71 ++++ packages/db/migrations/0027_pi_upstreams.ts | 76 ++++ packages/db/src/types.ts | 27 ++ 8 files changed, 665 insertions(+), 4 deletions(-) create mode 100644 packages/contracts/src/pi.ts create mode 100644 packages/contracts/test/pi.test.ts create mode 100644 packages/db/migrations/0027_pi_upstreams.ts diff --git a/packages/contracts/src/api.ts b/packages/contracts/src/api.ts index dd1d12fc..2f58bed5 100644 --- a/packages/contracts/src/api.ts +++ b/packages/contracts/src/api.ts @@ -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: { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 1405ef46..be807ee5 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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"; diff --git a/packages/contracts/src/pi.ts b/packages/contracts/src/pi.ts new file mode 100644 index 00000000..32cdae73 --- /dev/null +++ b/packages/contracts/src/pi.ts @@ -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; + +/** api_key:存一把 key(可附带 env);oauth:订阅登录 token 包;none:不存密钥(fake、无鉴权本地端点、云环境凭据链) */ +export const PiAuthKind = z.enum(["api_key", "oauth", "none"]); +export type PiAuthKind = z.infer; + +/** + * 上游状态(服务端维护,只读): + * ok = 凭据就绪;auth_required = 需要填 key 或登录;expired = OAuth 刷新失败,需要重新登录;error = 最近一次测试/调用失败 + */ +export const PiUpstreamStatus = z.enum(["ok", "auth_required", "expired", "error"]); +export type PiUpstreamStatus = z.infer; + +/** pi 的思考档位(比 settings.ThinkingLevel 多一个 max) */ +export const PiThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh", "max"]); +export type PiThinkingLevel = z.infer; + +/** 订阅登录方式:browser = 打开授权链接后把地址栏 URL 粘回来;device = 在任意设备输入验证码,服务端轮询 */ +export const PiOAuthMode = z.enum(["browser", "device"]); +export type PiOAuthMode = z.infer; + +/** 自定义端点可选的 API 形状 */ +export const PiCustomApi = z.enum([ + "openai-completions", + "openai-responses", + "anthropic-messages", + "google-generative-ai", +]); +export type PiCustomApi = z.infer; + +/** 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; + +// ── 能力表(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; + +export const PiEnvField = z.object({ + name: EnvName, + label: z.string(), + required: z.boolean(), + hint: z.string().nullable(), +}); +export type PiEnvField = z.infer; + +export const PiProviderGroup = z.enum(["subscription", "api_key", "cloud", "custom", "test"]); +export type PiProviderGroup = z.infer; + +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; + +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; + +// ── 上游 ── + +/** 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; + +/** 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; + +export const PiUpstreamList = z.object({ + items: z.array(PiUpstream), + defaultUpstreamId: Id.nullable(), +}); +export type PiUpstreamList = z.infer; + +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; + +/** 修改上游: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; + +// ── 测试 / 模型 ── + +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; + +export const PiTestErrorKind = z.enum(["auth", "oauth", "model", "network", "config", "other"]); +export type PiTestErrorKind = z.infer; + +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; + +export const PiUpstreamModels = z.object({ + items: z.array(PiModelInfo), + /** 动态模型表是否已从上游刷新成功(静态 provider 恒为 false) */ + refreshed: z.boolean(), + /** 动态刷新失败原因(仍返回静态表) */ + refreshError: z.string().nullable(), +}); +export type PiUpstreamModels = z.infer; + +// ── 订阅登录状态机 ── + +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; + +/** + * 登录会话状态: + * pending = 等用户去授权(browser 等粘贴,device 服务端轮询中);succeeded = 已写入凭据; + * failed = 流程报错(message 可读);expired = 会话超时;cancelled = 被新会话/登出取消 + */ +export const PiOAuthState = z.enum(["pending", "succeeded", "failed", "expired", "cancelled"]); +export type PiOAuthState = z.infer; + +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; + +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; + +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; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index bde3aa41..de1068a4 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -50,11 +50,23 @@ export type RetentionSettings = z.infer; export const ThinkingLevel = z.enum(["off", "minimal", "low", "medium", "high", "xhigh"]); export type ThinkingLevel = z.infer; -/** 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; /** * 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(), diff --git a/packages/contracts/test/db-sync.test.ts b/packages/contracts/test/db-sync.test.ts index 30fe27b5..df42b090 100644 --- a/packages/contracts/test/db-sync.test.ts +++ b/packages/contracts/test/db-sync.test.ts @@ -39,7 +39,8 @@ type Obj = z.ZodObject; const ROWS: Record = { 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 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 = { 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, diff --git a/packages/contracts/test/pi.test.ts b/packages/contracts/test/pi.test.ts new file mode 100644 index 00000000..3e31821b --- /dev/null +++ b/packages/contracts/test/pi.test.ts @@ -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"); + }); +}); diff --git a/packages/db/migrations/0027_pi_upstreams.ts b/packages/db/migrations/0027_pi_upstreams.ts new file mode 100644 index 00000000..198be2a7 --- /dev/null +++ b/packages/db/migrations/0027_pi_upstreams.ts @@ -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): Promise { + 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 }>`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): Promise { + // 默认上游的密钥交还给旧字段,再删表 + 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;", + ); +} diff --git a/packages/db/src/types.ts b/packages/db/src/types.ts index 5a491115..3b0a44ec 100644 --- a/packages/db/src/types.ts +++ b/packages/db/src/types.ts @@ -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; @@ -996,6 +1000,25 @@ export interface PolicyRevisionTable { policy_key: string; revision: number; kind: PolicyRevisionKind; +export interface PiUpstreamTable { + id: Generated; + name: string; + provider: string; + auth_kind: PiUpstreamAuthKind; + secret_ref: string | null; + base_url: string | null; + default_model: string | null; + thinking_level: Generated; + extra: Generated; + enabled: Generated; + status: Generated; + last_error: string | null; + last_verified_at: Timestamptz | null; + oauth_expires_at: Timestamptz | null; + created_at: GeneratedTimestamptz; + updated_at: GeneratedTimestamptz; +} + status: Generated; title: string; summary: string | null; @@ -1446,6 +1469,7 @@ export type AgentRunMessageRow = Selectable; export type NewAgentRunMessage = Insertable; export type AgentRunMessageUpdate = Updateable; export type ApiKeyRow = Selectable; + pi_upstream: PiUpstreamTable; export type NewApiKey = Insertable; export type ApiKeyUpdate = Updateable; export type ApprovalRow = Selectable; @@ -1599,6 +1623,9 @@ export type SubscriptionRow = Selectable; export type NewSubscription = Insertable; export type SubscriptionUpdate = Updateable; export type SubscriptionHistoryRow = Selectable; +export type PiUpstreamRow = Selectable; +export type NewPiUpstream = Insertable; +export type PiUpstreamUpdate = Updateable; export type NewSubscriptionHistory = Insertable; export type SubscriptionHistoryUpdate = Updateable; export type TaskRow = Selectable;