Files
UHD_Media_Tool/packages/contracts/src/pi.ts
T
rootandClaude Opus 5.5 fa344a7184 feat(web): token 数以 K 输入显示,已存凭证显示掩码占位(FIX-8)
- 新增 UnitInput 与 kind=tokens:上下文预算、单次 token 上限、自定义模型上下文窗口以 K 输入(允许小数),存储仍为原始 token 数;校验与提示用 K 表述;列表/详情 token 数统一 K/M 简写(formatTokens)
- 新增 SecretInput 与 kind=secret:已保存时显示「已设置 ••••••••」(有服务端掩码时带末 4 位),聚焦直接输入替换,留空保持,「清除」确认后保存生效;替换模型服务、TMDB、网页搜索、下载器、媒体服务器、浏览器服务、站点账号、Passkey、本机 Token 各处
- 统一只写凭证服务端语义:省略或空串 = 不变,null = 清除(contracts secretInput);下载器/媒体服务器新增清除;站点凭证同类更新可留空保持密码/Cookie/API Key/TOTP,TOTP 可 null 清除,不再误删 TOTP 与 UA
- contextBudget 最小值 4096→4000、contextWindow 1024→1000,便于按 K 填写
- 测试:换算与校验、SecretInput 渲染与状态、各接口留空不变/显式清除

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 00:10:46 +08:00

406 lines
16 KiB
TypeScript

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(1000).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().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?.trim())
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>;
/** 模型列表来源:remote = 从上游的模型列表接口拉取(已存库);static = 随包静态目录(没拉过、拉取失败或 provider 没有列表接口) */
export const PiModelListSource = z.enum(["remote", "static"]);
export type PiModelListSource = z.infer<typeof PiModelListSource>;
/** 列表里的一个模型(字段按上游接口能给的归一化;拿不到为 null) */
export const PiListedModel = z.object({
id: z.string().min(1).max(300),
displayName: z.string().max(300).nullable(),
contextWindow: z.number().int().nonnegative().nullable(),
/** 支持图片输入 */
supportsVision: z.boolean().nullable(),
/** 支持工具调用 */
supportsTools: z.boolean().nullable(),
/** 价格(美元 / 百万 token) */
pricing: z.object({ input: z.number().nullable(), output: z.number().nullable() }).nullable(),
/** 这一条来自哪里:remote = 上游列表;static = 只在静态目录里 */
origin: PiModelListSource,
});
export type PiListedModel = z.infer<typeof PiListedModel>;
/** 获取失败的分类:unsupported = provider 没有模型列表接口 */
export const PiModelFetchErrorKind = z.enum(["auth", "network", "unsupported", "config", "other"]);
export type PiModelFetchErrorKind = z.infer<typeof PiModelFetchErrorKind>;
/**
* GET /pi/upstreams/{id}/models、POST …/models/refresh、POST /pi/models/probe 的响应。
* models = 远程列表与静态目录合并去重(同 id 远程优先,远程缺的字段用静态目录补),远程在前。
*/
export const PiUpstreamModels = z.object({
source: PiModelListSource,
/** 远程列表拉取时间;static 为 null */
fetchedAt: Timestamp.nullable(),
models: z.array(PiListedModel),
/** 该 provider 有模型列表接口(没有时「获取模型列表」直接回静态表) */
remoteSupported: z.boolean(),
/** 本次获取失败的原因(一句话,已脱敏);成功或只是读取时为 null */
error: z.string().nullable(),
errorKind: PiModelFetchErrorKind.nullable(),
});
export type PiUpstreamModels = z.infer<typeof PiUpstreamModels>;
/** 新建上游前试拉模型列表(不落库;凭证只用于这一次请求) */
export const PiModelsProbeRequest = z.object({
provider: PiProviderId,
authKind: PiAuthKind,
apiKey: ApiKeyInput.optional(),
baseUrl: z.url().nullable().optional(),
extra: PiUpstreamExtra.optional(),
});
export type PiModelsProbeRequest = z.infer<typeof PiModelsProbeRequest>;
// ── 订阅登录状态机 ──
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>;