feat(server): pi module with upstream CRUD, server-side OAuth and token refresh

- /pi/providers, /pi/upstreams CRUD, test (auth-only by default, no cost), models
- subscription login via pi models.login with a suspended interaction: browser mode
  waits for the pasted redirect URL, device mode polls server-side; per-provider
  login lock, readable callback-port-in-use errors
- credential store locks the pi_upstream row (select for update) so api and worker
  refreshes never double-write a rotated refresh token
- refreshPiTokens task: refresh before expiry; failure marks expired and opens a
  relogin decision, closed again after a successful login
- runAgent picks upstreamId > agent_run.upstream_id > default upstream > legacy
  settings; credentials injected by the server, never logged

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 02:23:22 +08:00
1 parent 743fe2486d
commit fd3ffe5a8d
13 files changed
+1617 -26

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
import { randomUUID } from "node:crypto";
import type { FakeOAuthControl } from "@umt/agent";
import { defineTask } from "@umt/tasks";
import { z } from "zod";
import type { ServerContext, ServerModule } from "../types.ts";
import { type OAuthOptions, OAuthSessions } from "./oauth.ts";
import { refreshPiTokens } from "./refresh.ts";
import { registerPiRoutes } from "./routes.ts";
export { type RunUpstream, resolveRunUpstream } from "./service.ts";
/**
* W7-A Pi 模型上游:pi_upstream CRUD、能力表、测试、模型表、服务端订阅登录(挂起式 interaction)、
* 定时任务 refreshPiTokens(OAuth 过期前刷新,行锁内写回)。runAgent 经 ./service.ts 的 resolveRunUpstream 取上游。
* 用法见 docs/runbook/agent.md「上游与登录」。
*/
export interface PiModuleOptions {
/** provider = fake 的模拟 OAuth(测试注入) */
fakeOAuth?: FakeOAuthControl;
oauth?: Omit<OAuthOptions, "client">;
/** 刷新任务入队间隔(默认 10 分钟;<=0 关闭) */
refreshIntervalMs?: number;
/** 剩余有效期少于它就刷新(默认 30 分钟) */
refreshWindowMs?: number;
}
export function createPiModule(
opts: PiModuleOptions = {},
): ServerModule & { sessions(ctx: ServerContext): OAuthSessions } {
const client = opts.fakeOAuth ? { fakeOAuth: opts.fakeOAuth } : undefined;
const sessions = new WeakMap<ServerContext, OAuthSessions>();
const sessionsFor = (ctx: ServerContext) => {
let s = sessions.get(ctx);
if (!s) {
s = new OAuthSessions(ctx, { ...opts.oauth, ...(client ? { client } : {}) });
sessions.set(ctx, s);
}
return s;
};
let timer: ReturnType<typeof setInterval> | null = null;
const live: OAuthSessions[] = [];
const interval = opts.refreshIntervalMs ?? 10 * 60_000;
const windowMs = opts.refreshWindowMs ?? 30 * 60_000;
return {
name: "pi",
sessions: sessionsFor,
registerRoutes(app, ctx) {
registerPiRoutes(app, ctx, {
oauth: () => {
const s = sessionsFor(ctx);
if (!live.includes(s)) live.push(s);
return s;
},
...(client ? { client } : {}),
});
},
registerTasks(registry, ctx) {
registry.register(
defineTask({
name: "refreshPiTokens",
version: 1,
inputSchema: z.object({}).passthrough(),
retryPolicy: { maximumAttempts: 2, initialInterval: "10s", backoffCoefficient: 2, maximumInterval: "60s" },
handler: async (t) =>
t.step.run("refresh", () => refreshPiTokens(ctx, { windowMs, ...(client ? { client } : {}) })),
}),
);
},
start(ctx) {
if (interval <= 0) return;
const enqueue = async () => {
try {
await ctx.engine.enqueue(
"refreshPiTokens",
{},
{ idempotencyKey: `refreshPiTokens:${Math.floor(Date.now() / interval)}`, traceId: randomUUID() },
);
} catch (err) {
ctx.logger.warn({ err: { message: (err as Error).message } }, "refreshPiTokens 入队失败");
}
};
void enqueue();
timer = setInterval(() => void enqueue(), interval);
},
async stop() {
if (timer) clearInterval(timer);
timer = null;
await Promise.all(live.splice(0).map((s) => s.shutdown()));
},
};
}
export const piModule = createPiModule();
+273
View File
@@ -0,0 +1,273 @@
import { randomUUID } from "node:crypto";
import {
classifyUpstreamError,
OAUTH_CALLBACK_PORTS,
PiUpstreamClient,
providerInfo,
type UpstreamAuthEvent,
type UpstreamAuthPrompt,
type UpstreamClientOptions,
type UpstreamLoginInteraction,
} from "@umt/agent";
import type { PiOAuthMode, PiOAuthStartResult, PiOAuthState } from "@umt/contracts";
import type { PiUpstreamRow } from "@umt/db";
import { ApiException } from "../../http/errors.ts";
import type { ServerContext } from "../types.ts";
import { dbCredentialStore, getRow, setStatus, specOf } from "./store.ts";
// 服务端订阅登录(研究文档 §3):调用 pi 的 models.login,注入“挂起式” interaction ——
// select 用 start 请求里的 mode 作答、text 用 enterpriseDomain 作答、manual_code 挂起等 /oauth/complete 粘贴;
// notify(auth_url | device_code) 写进会话记录,start 拿到第一个事件就返回。
// 会话只活在当前 api 进程内存(interaction 是闭包):多实例部署时登录请求要粘到同一实例。
// 同一 provider 的登录串行(pi 的浏览器流程会在服务端监听固定回调端口,并发会撞端口)。
export interface OAuthOptions {
/** 会话有效期(默认 15 分钟) */
sessionTtlMs?: number;
/** start 等第一个事件的上限(默认 20s) */
startWaitMs?: number;
/** complete 缺省等待(默认 25s) */
completeWaitMs?: number;
client?: UpstreamClientOptions;
}
interface Session {
id: string;
upstreamId: string;
provider: string;
mode: PiOAuthMode;
state: PiOAuthState;
message: string | null;
progress: string[];
authorizeUrl: string | null;
userCode: string | null;
verificationUrl: string | null;
instructions: string | null;
expiresAt: number;
abort: AbortController;
/** manual_code 挂起的解析函数 */
paste: ((v: string) => void) | null;
pasteWaiters: (() => void)[];
firstEvent: PromiseWithResolvers<void>;
done: Promise<void>;
timer: ReturnType<typeof setTimeout>;
}
const MODE_RE: Record<PiOAuthMode, RegExp> = { browser: /browser/i, device: /device/i };
/** 登录失败信息转成可读提示(端口占用、取消、超时) */
export function readableLoginError(provider: string, err: unknown): string {
const raw = err instanceof Error ? err.message : String(err);
if (/EADDRINUSE|address already in use/i.test(raw)) {
const port = OAUTH_CALLBACK_PORTS[provider] ?? raw.match(/:(\d{2,5})\b/)?.[1];
return `服务端回调端口${port ? ` ${port}` : ""} 被占用(可能另有登录进行中,或容器内有 Claude Code / Codex CLI 占用该端口),请释放端口或稍后重试`;
}
if (/cancel|abort/i.test(raw)) return "登录已取消";
if (/state mismatch/i.test(raw)) return "粘贴的地址与本次登录不匹配(state 不一致),请用本次打开的授权链接重新登录";
if (/\b404\b/.test(raw) && provider === "openai-codex")
return "该账号未开启设备码登录(Codex 返回 404),请改用浏览器登录";
return classifyUpstreamError(err).message;
}
export class OAuthSessions {
private readonly sessions = new Map<string, Session>();
constructor(
private readonly ctx: ServerContext,
private readonly opts: OAuthOptions = {},
) {}
private active(provider: string): Session | undefined {
for (const s of this.sessions.values()) if (s.provider === provider && s.state === "pending") return s;
return undefined;
}
private finish(s: Session, state: PiOAuthState, message: string | null) {
if (s.state !== "pending") return;
s.state = state;
s.message = message;
s.firstEvent.resolve();
for (const w of s.pasteWaiters.splice(0)) w();
clearTimeout(s.timer);
// 结束后保留一会儿供 complete 查询结果
s.timer = setTimeout(() => this.sessions.delete(s.id), 5 * 60_000);
(s.timer as { unref?: () => void }).unref?.();
}
cancel(s: Session, reason: PiOAuthState = "cancelled", message = "登录已取消") {
this.finish(s, reason, message);
s.abort.abort(new Error(message));
}
cancelUpstream(upstreamId: string) {
for (const s of this.sessions.values()) if (s.upstreamId === upstreamId && s.state === "pending") this.cancel(s);
}
async start(row: PiUpstreamRow, req: { mode?: PiOAuthMode; enterpriseDomain?: string }): Promise<PiOAuthStartResult> {
const info = providerInfo(row.provider);
if (row.auth_kind !== "oauth" || !info?.oauth)
throw new ApiException(
"precondition_failed",
"该上游不是订阅登录型(authKind 需为 oauth,且 provider 支持订阅登录)",
);
if (!row.enabled) throw new ApiException("precondition_failed", "上游已停用");
const mode = req.mode ?? info.oauth.modes[0]!;
if (!info.oauth.modes.includes(mode))
throw new ApiException(
"validation_failed",
`${info.name} 不支持 ${mode} 登录,可用:${info.oauth.modes.join(" / ")}`,
);
const busy = this.active(row.provider);
if (busy) {
if (busy.upstreamId !== row.id)
throw new ApiException(
"conflict",
`${info.name} 已有登录进行中(同一 provider 的登录需串行),请先完成或等待其过期(${new Date(busy.expiresAt).toISOString()})`,
);
this.cancel(busy, "cancelled", "已被新的登录请求取代");
await busy.done.catch(() => undefined);
}
const ttl = this.opts.sessionTtlMs ?? 15 * 60_000;
const s: Session = {
id: randomUUID(),
upstreamId: row.id,
provider: row.provider,
mode,
state: "pending",
message: null,
progress: [],
authorizeUrl: null,
userCode: null,
verificationUrl: null,
instructions: null,
expiresAt: Date.now() + ttl,
abort: new AbortController(),
paste: null,
pasteWaiters: [],
firstEvent: Promise.withResolvers<void>(),
done: Promise.resolve(),
timer: setTimeout(() => this.cancel(s, "expired", "登录会话已超时,请重新开始"), ttl),
};
(s.timer as { unref?: () => void }).unref?.();
this.sessions.set(s.id, s);
const interaction: UpstreamLoginInteraction = {
signal: s.abort.signal,
prompt: (p: UpstreamAuthPrompt) => this.prompt(s, p, req),
notify: (e: UpstreamAuthEvent) => this.notify(s, e),
};
const client = new PiUpstreamClient(specOf(row), dbCredentialStore(this.ctx, row.id), this.opts.client);
s.done = client.login(interaction).then(
async () => {
await setStatus(this.ctx, row.id, { status: "ok", verified: true });
this.finish(s, "succeeded", null);
},
async (err) => {
if (s.state !== "pending") return; // 已取消/超时
const message = readableLoginError(row.provider, err);
this.ctx.logger.warn({ upstreamId: row.id, provider: row.provider, message }, "订阅登录失败");
this.finish(s, "failed", message);
},
);
const waitMs = this.opts.startWaitMs ?? 20_000;
await Promise.race([s.firstEvent.promise, new Promise((r) => setTimeout(r, waitMs).unref?.())]);
return this.startView(s);
}
private async prompt(s: Session, p: UpstreamAuthPrompt, req: { mode?: PiOAuthMode; enterpriseDomain?: string }) {
if (p.type === "select") {
const hit = p.options.find((o) => MODE_RE[s.mode].test(`${o.id} ${o.label}`));
if (!hit) throw new Error(`provider 不提供 ${s.mode} 登录方式`);
return hit.id;
}
if (p.type === "text") return req.enterpriseDomain ?? "";
if (p.type === "manual_code") {
return await new Promise<string>((resolve, reject) => {
const onAbort = () => reject(new Error("Login cancelled"));
p.signal?.addEventListener("abort", onAbort, { once: true });
s.abort.signal.addEventListener("abort", onAbort, { once: true });
s.paste = (v) => {
s.paste = null;
resolve(v);
};
for (const w of s.pasteWaiters.splice(0)) w();
});
}
throw new Error("该登录流程需要额外输入(secret),服务端登录不支持");
}
private notify(s: Session, e: UpstreamAuthEvent) {
if (e.type === "auth_url") {
s.authorizeUrl = e.url;
s.instructions = e.instructions ?? null;
s.mode = "browser";
s.firstEvent.resolve();
} else if (e.type === "device_code") {
s.userCode = e.userCode;
s.verificationUrl = e.verificationUri;
s.mode = "device";
if (e.expiresInSeconds) s.expiresAt = Math.min(s.expiresAt, Date.now() + e.expiresInSeconds * 1000);
s.firstEvent.resolve();
} else {
s.progress.push(e.message.slice(0, 300));
if (s.progress.length > 20) s.progress.shift();
if (e.type === "info" && !s.instructions) s.instructions = e.message.slice(0, 500);
}
}
private startView(s: Session): PiOAuthStartResult {
return {
sessionId: s.id,
mode: s.mode,
state: s.state,
authorizeUrl: s.authorizeUrl,
userCode: s.userCode,
verificationUrl: s.verificationUrl,
instructions: s.instructions,
expiresAt: new Date(s.expiresAt).toISOString(),
message: s.message,
};
}
/** browser:把粘贴内容交给挂起的 manual_code;然后(两种模式)等结果到 waitMs,仍未完成返回 pending */
async complete(
upstreamId: string,
req: { sessionId: string; pastedUrlOrCode?: string; waitMs?: number },
): Promise<{ sessionId: string; state: PiOAuthState; message: string | null; progress: string[] }> {
const s = this.sessions.get(req.sessionId);
if (!s || s.upstreamId !== upstreamId)
return { sessionId: req.sessionId, state: "expired", message: "登录会话不存在或已过期,请重新开始", progress: [] };
if (req.pastedUrlOrCode && s.state === "pending") {
if (s.mode !== "browser") throw new ApiException("bad_request", "设备码登录不需要粘贴,等待授权完成即可");
if (!s.paste) {
// manual_code 提示可能还没发出:短暂等待
await Promise.race([
new Promise<void>((r) => s.pasteWaiters.push(r)),
new Promise((r) => setTimeout(r, 2000).unref?.()),
]);
}
if (!s.paste) throw new ApiException("conflict", "当前登录不在等待粘贴的阶段");
s.paste(req.pastedUrlOrCode);
}
const waitMs = req.waitMs ?? this.opts.completeWaitMs ?? 25_000;
if (s.state === "pending")
await Promise.race([s.done, new Promise((r) => setTimeout(r, waitMs).unref?.())]).catch(() => undefined);
return { sessionId: s.id, state: s.state, message: s.message, progress: [...s.progress] };
}
async shutdown() {
for (const s of this.sessions.values()) {
if (s.state === "pending") this.cancel(s, "cancelled", "服务正在停止");
clearTimeout(s.timer);
}
this.sessions.clear();
}
}
export async function requireRow(ctx: ServerContext, id: string): Promise<PiUpstreamRow> {
const row = await getRow(ctx, id);
if (!row) throw new ApiException("not_found", "上游不存在");
return row;
}
+109
View File
@@ -0,0 +1,109 @@
import { PiUpstreamClient, type UpstreamClientOptions } from "@umt/agent";
import { type PiUpstreamRow, sql } from "@umt/db";
import type { ServerContext } from "../types.ts";
import { dbCredentialStore, setStatus, specOf } from "./store.ts";
// refreshPiTokens:对即将过期的 OAuth 上游提前刷新。刷新本身走 pi 的 getAuth(minOAuthValidityMs):
// pi 在我们的 CredentialStore.modify(pi_upstream 行锁)里双重检查过期时间,再调 provider 的 refresh 并写回轮换后的
// token —— Agent 运行时(worker)与测试端点(api)的按需刷新走的是同一条路径,不会双写。
// 刷新失败(refresh token 失效)→ status = expired + 待处理事项“上游 X 需要重新登录”;旧凭据保留。
export interface RefreshOptions {
/** 剩余有效期少于它就刷新(默认 30 分钟) */
windowMs?: number;
client?: UpstreamClientOptions;
}
export interface RefreshReport {
checked: number;
refreshed: number;
failed: { upstreamId: string; message: string }[];
}
const DECISION_KEY = (id: string) => `pi_upstream_expired:${id}`;
/** 与其它模块的 upsertDecision 同一写法(跨模块不 import):同键未关闭时只累加 attempts */
async function upsertExpiredDecision(ctx: ServerContext, row: PiUpstreamRow, message: string) {
const title = `模型上游「${row.name}」登录已失效,需要重新登录`;
await ctx.db.transaction().execute(async (trx) => {
const r = await sql<{ id: string; inserted: boolean }>`
insert into decision (kind, severity, title, summary, subject_type, subject_id, dedupe_key, evidence, options)
values ('other', 'warning', ${title}, ${message}, 'pi_upstream', ${row.id}, ${DECISION_KEY(row.id)},
${JSON.stringify({ provider: row.provider, upstreamId: row.id, error: message })}::jsonb,
${JSON.stringify([{ key: "relogin", label: "去设置页重新登录", description: "设置 → 模型上游 → 登录" }])}::jsonb)
on conflict (dedupe_key) where status in ('open', 'investigating', 'awaiting_user')
do update set attempts = decision.attempts + 1, evidence = excluded.evidence, summary = excluded.summary,
updated_at = now()
returning id, (xmax = 0) as inserted`.execute(trx);
const d = r.rows[0]!;
if (d.inserted)
await ctx.outbox.emit(
trx,
"decision.created",
{ decisionId: d.id, kind: "other", severity: "warning", title },
{ idempotencyKey: `decision.created:${d.id}` },
);
});
}
/** 重新登录成功后关闭“需要重新登录”事项 */
export async function resolveExpiredDecision(ctx: ServerContext, upstreamId: string) {
await ctx.db
.updateTable("decision")
.set({
status: "resolved",
resolved_at: new Date(),
resolved_by: "system:pi",
resolution: JSON.stringify({ key: "relogin" }),
})
.where("dedupe_key", "=", DECISION_KEY(upstreamId))
.where("status", "in", ["open", "investigating", "awaiting_user"])
.execute();
}
/** 刷新单个上游(api 测试端点与定时任务共用) */
export async function refreshUpstream(
ctx: ServerContext,
row: PiUpstreamRow,
opts: RefreshOptions = {},
): Promise<{ ok: true } | { ok: false; message: string; kind: string }> {
const client = new PiUpstreamClient(specOf(row), dbCredentialStore(ctx, row.id), opts.client);
try {
await client.resolveAuth(opts.windowMs ?? 30 * 60_000);
return { ok: true };
} catch (err) {
const e = err as { kind?: string; message?: string };
const message = e.message ?? String(err);
if (e.kind === "oauth") {
await setStatus(ctx, row.id, { status: "expired", lastError: message });
await upsertExpiredDecision(ctx, row, message);
} else if (e.kind === "auth") {
await setStatus(ctx, row.id, { status: "auth_required", lastError: message });
}
return { ok: false, message, kind: e.kind ?? "other" };
}
}
export async function refreshPiTokens(ctx: ServerContext, opts: RefreshOptions = {}): Promise<RefreshReport> {
const windowMs = opts.windowMs ?? 30 * 60_000;
const rows = await ctx.db
.selectFrom("pi_upstream")
.selectAll()
.where("auth_kind", "=", "oauth")
.where("enabled", "=", true)
.where("secret_ref", "is not", null)
.where("status", "!=", "expired")
.where("oauth_expires_at", "is not", null)
.where("oauth_expires_at", "<", new Date(Date.now() + windowMs))
.execute();
const report: RefreshReport = { checked: rows.length, refreshed: 0, failed: [] };
for (const row of rows) {
const r = await refreshUpstream(ctx, row, { ...opts, windowMs });
if (r.ok) report.refreshed += 1;
else {
report.failed.push({ upstreamId: row.id, message: r.message });
ctx.logger.warn({ upstreamId: row.id, provider: row.provider, message: r.message }, "Pi 上游 token 刷新失败");
}
}
return report;
}
+163
View File
@@ -0,0 +1,163 @@
import { createRoute, type RouteConfig } from "@hono/zod-openapi";
import { providerCatalog, type UpstreamClientOptions, UpstreamError } from "@umt/agent";
import {
type Actor,
Api,
apiResponse,
type EndpointDef,
type PiOAuthCompleteRequest,
type PiOAuthStartRequest,
type PiUpstreamCreate,
type PiUpstreamTestRequest,
type PiUpstreamUpdate,
} from "@umt/contracts";
import type { z } from "zod";
import { requireAdminSession, requireScope } from "../../http/auth.ts";
import { ApiException } from "../../http/errors.ts";
import type { ModuleApp, ServerContext } from "../types.ts";
import type { OAuthSessions } from "./oauth.ts";
import { requireRow } from "./oauth.ts";
import { refreshUpstream, resolveExpiredDecision } from "./refresh.ts";
import { auditPi, clientFor, createUpstream, deleteUpstream, listUpstreams, updateUpstream } from "./service.ts";
import { getRow, setStatus, toView } from "./store.ts";
type Infer<T> = T extends z.ZodType ? z.infer<T> : Record<string, never>;
interface Input<D extends EndpointDef> {
params: Infer<D["params"]>;
query: Infer<D["query"]>;
body: Infer<D["body"]>;
actor: Actor | undefined;
}
/** 薄封装:合同 EndpointDef → OpenAPI 路由 + requireScope(+ session)+ 出参按合同 parse */
function route<D extends EndpointDef>(app: ModuleApp, def: D, handler: (i: Input<D>) => Promise<unknown>) {
const request: RouteConfig["request"] = {};
if (def.params) request.params = def.params as never;
if (def.query) request.query = def.query as never;
if (def.body) request.body = { content: { "application/json": { schema: def.body as never } }, required: true };
const r = createRoute({
method: def.method,
path: def.path,
summary: def.summary,
tags: ["pi"],
middleware: [requireScope(def.scope), ...(def.auth === "session" ? [requireAdminSession()] : [])] as never,
request,
responses: {
200: { description: def.summary, content: { "application/json": { schema: apiResponse(def.response) } } },
},
});
// biome-ignore lint/suspicious/noExplicitAny: 路由泛型在封装里擦除,入参已由 zod-openapi 校验
app.openapi(r, (async (c: any) => {
const input = {
params: def.params ? c.req.valid("param") : {},
query: def.query ? c.req.valid("query") : {},
body: def.body ? c.req.valid("json") : {},
actor: c.get("actor"),
} as Input<D>;
const data = def.response.parse(await handler(input));
return c.json({ ok: true, data }, 200);
// biome-ignore lint/suspicious/noExplicitAny: 同上
}) as any);
}
export function registerPiRoutes(
app: ModuleApp,
ctx: ServerContext,
deps: { oauth: () => OAuthSessions; client?: UpstreamClientOptions },
) {
route(app, Api.pi.providers, async ({ query }) => providerCatalog({ models: query.models !== "false" }));
route(app, Api.pi.list, async () => await listUpstreams(ctx));
route(app, Api.pi.get, async ({ params }) => await toView(ctx, await requireRow(ctx, params.id)));
route(app, Api.pi.create, async ({ body, actor }) => await createUpstream(ctx, actor, body as PiUpstreamCreate));
route(app, Api.pi.update, async ({ params, body, actor }) => {
const row = await requireRow(ctx, params.id);
return await updateUpstream(ctx, actor, row, body as PiUpstreamUpdate, () => deps.oauth().cancelUpstream(row.id));
});
route(app, Api.pi.remove, async ({ params, actor }) => {
const row = await requireRow(ctx, params.id);
deps.oauth().cancelUpstream(row.id);
await deleteUpstream(ctx, actor, row);
return { ok: true as const };
});
route(app, Api.pi.test, async ({ params, body }) => {
const row = await requireRow(ctx, params.id);
const req = body as PiUpstreamTestRequest;
const mode = req.mode ?? "auth";
const started = Date.now();
let out: {
ok: boolean;
source: string | null;
model: string | null;
errorKind: UpstreamError["kind"] | null;
message: string | null;
};
try {
// OAuth 型先走与定时任务同一条刷新路径(失败会标 expired 并建待处理事项)
if (row.auth_kind === "oauth" && row.secret_ref) {
const r = await refreshUpstream(ctx, row, { windowMs: 0, ...(deps.client ? { client: deps.client } : {}) });
if (!r.ok) throw new UpstreamError(r.kind as UpstreamError["kind"], r.message);
}
const t = await clientFor(ctx, row, deps.client).test({ mode, ...(req.model ? { model: req.model } : {}) });
out = {
ok: true,
source: t.source,
model: t.model,
errorKind: null,
message: t.inCatalog ? null : `模型 ${t.model} 不在随包目录中,将按手填 id 调用`,
};
await setStatus(ctx, row.id, { status: "ok", verified: true });
} catch (err) {
const e = err instanceof UpstreamError ? err : new UpstreamError("other", (err as Error).message);
out = { ok: false, source: null, model: req.model ?? row.default_model, errorKind: e.kind, message: e.message };
const fresh = await getRow(ctx, row.id);
if (fresh && fresh.status !== "expired")
await setStatus(ctx, row.id, {
status: e.kind === "auth" ? "auth_required" : "error",
lastError: e.message,
});
}
return { ...out, mode, latencyMs: Date.now() - started, upstream: await toView(ctx, (await getRow(ctx, row.id))!) };
});
route(app, Api.pi.models, async ({ params }) => {
const row = await requireRow(ctx, params.id);
try {
return await clientFor(ctx, row, deps.client).listModels();
} catch (err) {
if (err instanceof UpstreamError) throw new ApiException("validation_failed", err.message);
throw err;
}
});
route(app, Api.pi.oauthStart, async ({ params, body, actor }) => {
const row = await requireRow(ctx, params.id);
const r = await deps.oauth().start(row, body as PiOAuthStartRequest);
await auditPi(ctx, actor, "pi_upstream.oauth_start", row.id, { mode: r.mode, sessionId: r.sessionId });
return r;
});
route(app, Api.pi.oauthComplete, async ({ params, body, actor }) => {
const row = await requireRow(ctx, params.id);
const req = body as PiOAuthCompleteRequest;
const r = await deps.oauth().complete(row.id, req);
if (r.state === "succeeded") {
await resolveExpiredDecision(ctx, row.id);
await auditPi(ctx, actor, "pi_upstream.oauth_login", row.id, { sessionId: r.sessionId });
}
return { ...r, upstream: await toView(ctx, (await getRow(ctx, row.id))!) };
});
route(app, Api.pi.oauthLogout, async ({ params, actor }) => {
const row = await requireRow(ctx, params.id);
deps.oauth().cancelUpstream(row.id);
await clientFor(ctx, row, deps.client).logout();
await auditPi(ctx, actor, "pi_upstream.oauth_logout", row.id);
return await toView(ctx, (await getRow(ctx, row.id))!);
});
}
+222
View File
@@ -0,0 +1,222 @@
import { randomUUID } from "node:crypto";
import {
type PiUpstreamClient as Client,
PiUpstreamClient,
providerInfo,
type UpstreamClientOptions,
type UpstreamCredentialStore,
type UpstreamSpec,
} from "@umt/agent";
import type { Actor, PiUpstream, PiUpstreamCreate, PiUpstreamUpdate } from "@umt/contracts";
import type { PiUpstreamRow } from "@umt/db";
import { ApiException } from "../../http/errors.ts";
import type { ServerContext } from "../types.ts";
import {
dbCredentialStore,
defaultUpstreamId,
getRow,
parseExtra,
setDefaultUpstream,
specOf,
toView,
} from "./store.ts";
// 上游 CRUD 与运行时取用。本文件的导出也供 agent 模块(runAgent)使用:按上游 id 取 spec + 凭据存储。
export function clientFor(ctx: ServerContext, row: PiUpstreamRow, opts?: UpstreamClientOptions): Client {
return new PiUpstreamClient(specOf(row), dbCredentialStore(ctx, row.id), opts);
}
/** 审计(action 为自由文本;失败只记日志) */
export async function auditPi(
ctx: ServerContext,
actor: Actor | undefined,
action: string,
id: string | null,
payload: Record<string, unknown> = {},
) {
try {
await ctx.db
.insertInto("audit_event")
.values({
actor_type: actor?.type === "api_key" ? "api_key" : actor ? "user" : "system",
actor_id: actor?.id ?? null,
action,
target_type: "pi_upstream",
target_id: id,
payload: JSON.stringify(payload),
})
.execute();
} catch (err) {
ctx.logger.warn({ err, action }, "审计写入失败");
}
}
function validateShape(input: {
provider: string;
authKind: string;
baseUrl?: string | null | undefined;
defaultModel?: string | null | undefined;
}) {
const info = providerInfo(input.provider);
if (!info) throw new ApiException("validation_failed", `未知 provider:${input.provider}`);
if (!info.authKinds.includes(input.authKind as never))
throw new ApiException(
"validation_failed",
`${info.name} 不支持认证方式 ${input.authKind},可用:${info.authKinds.join(" / ")}`,
);
if (input.baseUrl && (!info.baseUrlOverridable || input.authKind === "oauth"))
throw new ApiException(
"validation_failed",
`${info.name}${input.authKind === "oauth" ? "(订阅登录)" : ""} 不能覆盖 baseUrl`,
);
if (input.provider === "openai-compatible" && (!input.baseUrl || !input.defaultModel))
throw new ApiException("validation_failed", "自定义兼容端点必须填 baseUrl 与模型 id");
return info;
}
const statusFor = (authKind: string, hasKey: boolean) =>
authKind === "none" || (authKind === "api_key" && hasKey) ? ("ok" as const) : ("auth_required" as const);
const isUnique = (err: unknown) => (err as { code?: string })?.code === "23505";
export async function listUpstreams(
ctx: ServerContext,
): Promise<{ items: PiUpstream[]; defaultUpstreamId: string | null }> {
const rows = await ctx.db.selectFrom("pi_upstream").selectAll().orderBy("created_at").orderBy("id").execute();
const def = await defaultUpstreamId(ctx);
return { items: await Promise.all(rows.map((r) => toView(ctx, r, def))), defaultUpstreamId: def };
}
export async function createUpstream(
ctx: ServerContext,
actor: Actor | undefined,
body: PiUpstreamCreate,
): Promise<PiUpstream> {
validateShape(body);
const id = randomUUID();
let secretRef: string | null = null;
if (body.apiKey) secretRef = await ctx.secrets.create({ purpose: "llm_api_key", plaintext: body.apiKey });
try {
await ctx.db
.insertInto("pi_upstream")
.values({
id,
name: body.name,
provider: body.provider,
auth_kind: body.authKind,
secret_ref: secretRef,
base_url: body.baseUrl ?? null,
default_model: body.defaultModel ?? null,
thinking_level: body.thinkingLevel ?? "medium",
extra: JSON.stringify(body.extra ?? {}),
enabled: body.enabled ?? true,
status: statusFor(body.authKind, !!secretRef),
})
.execute();
} catch (err) {
if (secretRef) await ctx.secrets.remove(secretRef).catch(() => {});
if (isUnique(err)) throw new ApiException("conflict", `上游名称「${body.name}」已存在`);
throw err;
}
if (body.makeDefault || !(await defaultUpstreamId(ctx))) await setDefaultUpstream(ctx, id);
await auditPi(ctx, actor, "pi_upstream.create", id, {
provider: body.provider,
authKind: body.authKind,
apiKey: body.apiKey ? "(set)" : undefined,
});
return await toView(ctx, (await getRow(ctx, id))!);
}
export async function updateUpstream(
ctx: ServerContext,
actor: Actor | undefined,
row: PiUpstreamRow,
body: PiUpstreamUpdate,
onCredentialDropped: () => void,
): Promise<PiUpstream> {
const authKind = body.authKind ?? row.auth_kind;
const baseUrl = body.baseUrl !== undefined ? body.baseUrl : authKind === "oauth" ? null : row.base_url;
const defaultModel = body.defaultModel !== undefined ? body.defaultModel : row.default_model;
validateShape({ provider: row.provider, authKind, baseUrl, defaultModel });
if (body.apiKey && authKind !== "api_key")
throw new ApiException("validation_failed", "只有 api_key 型上游可以填 apiKey");
const kindChanged = authKind !== row.auth_kind;
const store = dbCredentialStore(ctx, row.id);
// 认证方式变了或清除 key:旧凭据作废(连同 OAuth 登录会话)
if (kindChanged || body.apiKey === null) {
await store.delete();
onCredentialDropped();
}
await ctx.db
.updateTable("pi_upstream")
.set({
...(body.name !== undefined ? { name: body.name } : {}),
auth_kind: authKind,
base_url: baseUrl,
default_model: defaultModel,
...(body.thinkingLevel !== undefined ? { thinking_level: body.thinkingLevel } : {}),
...(body.extra !== undefined ? { extra: JSON.stringify(body.extra) } : {}),
...(body.enabled !== undefined ? { enabled: body.enabled } : {}),
})
.where("id", "=", row.id)
.execute()
.catch((err) => {
if (isUnique(err)) throw new ApiException("conflict", `上游名称「${body.name}」已存在`);
throw err;
});
if (typeof body.apiKey === "string") {
const key = body.apiKey;
await store.modify(async () => ({ type: "api_key", key }));
}
// 状态按新配置重算(OAuth 已登录的保持)
const fresh = (await getRow(ctx, row.id))!;
if (fresh.auth_kind !== "oauth" && (kindChanged || body.apiKey !== undefined || body.extra !== undefined)) {
await ctx.db
.updateTable("pi_upstream")
.set({ status: statusFor(fresh.auth_kind, !!fresh.secret_ref), last_error: null })
.where("id", "=", row.id)
.execute();
}
const def = await defaultUpstreamId(ctx);
if (body.makeDefault === true && def !== row.id) await setDefaultUpstream(ctx, row.id);
if (body.makeDefault === false && def === row.id) await setDefaultUpstream(ctx, null);
await auditPi(ctx, actor, "pi_upstream.update", row.id, {
...body,
...(body.apiKey !== undefined ? { apiKey: body.apiKey === null ? "(cleared)" : "(set)" } : {}),
});
return await toView(ctx, (await getRow(ctx, row.id))!);
}
export async function deleteUpstream(ctx: ServerContext, actor: Actor | undefined, row: PiUpstreamRow) {
await ctx.db.deleteFrom("pi_upstream").where("id", "=", row.id).execute();
if (row.secret_ref) await ctx.secrets.remove(row.secret_ref).catch(() => {});
if ((await defaultUpstreamId(ctx)) === row.id) await setDefaultUpstream(ctx, null);
await auditPi(ctx, actor, "pi_upstream.delete", row.id, { provider: row.provider, name: row.name });
}
// ── 运行时(agent 模块 runAgent 使用)──
export interface RunUpstream {
row: PiUpstreamRow;
spec: UpstreamSpec;
credentials: UpstreamCredentialStore;
}
/**
* 取运行用的上游:显式 upstreamId 优先,否则 settings.pi.defaultUpstreamId;都没有返回 null(调用方走旧字段兼容)。
* 上游停用 / 未登录 / 登录失效时抛出可读错误。
*/
export async function resolveRunUpstream(ctx: ServerContext, upstreamId?: string | null): Promise<RunUpstream | null> {
// 直接读库(不走 30s 设置缓存),删除/切换默认上游后立即生效
const id = upstreamId ?? (await defaultUpstreamId(ctx));
if (!id) return null;
const row = await getRow(ctx, id);
if (!row) throw new Error(upstreamId ? `指定的上游 ${upstreamId} 不存在` : "默认上游已不存在,请在设置里重新选择");
if (!row.enabled) throw new Error(`上游「${row.name}」已停用`);
if (row.status === "expired") throw new Error(`上游「${row.name}」登录已失效,请在设置里重新登录`);
if (row.status === "auth_required")
throw new Error(`上游「${row.name}」${row.auth_kind === "oauth" ? "尚未登录" : "未配置 API Key"}`);
return { row, spec: { ...specOf(row), extra: parseExtra(row.extra) }, credentials: dbCredentialStore(ctx, row.id) };
}
+219
View File
@@ -0,0 +1,219 @@
import type { UpstreamCredential, UpstreamCredentialStore, UpstreamSpec } from "@umt/agent";
import {
DEFAULT_SETTINGS,
type PiUpstream,
type PiUpstreamRow as PiUpstreamApiRow,
PiUpstreamExtra,
} from "@umt/contracts";
import { type PiUpstreamRow, sql } from "@umt/db";
import type { ServerContext } from "../types.ts";
// pi_upstream 的读写与凭据存储。凭据(API Key 明文或 OAuth token 包 JSON)在 secret 表,本表只存 secret_ref。
// 写凭据一律经 dbCredentialStore.modify:事务内 `select … for update` 锁住上游行 → 读当前凭据 → fn → 写回,
// api 与 worker 两个进程的刷新都走这里,refresh token 轮换不会被并发刷新打断。
/** 设置变更事件名(与 auth-settings 的 SETTINGS_CHANGED_EVENT 一致;跨模块不 import) */
const SETTINGS_CHANGED = "settings.changed";
/** 超过这个值视为不过期(OpenRouter 换到的是永久 key,expires = MAX_SAFE_INTEGER) */
const NEVER_EXPIRES_MS = 8.64e15;
type Ctx = Pick<ServerContext, "db" | "secrets" | "settings" | "events" | "logger">;
export async function getRow(ctx: Pick<ServerContext, "db">, id: string): Promise<PiUpstreamRow | undefined> {
return await ctx.db.selectFrom("pi_upstream").selectAll().where("id", "=", id).executeTakeFirst();
}
export function specOf(row: PiUpstreamRow): UpstreamSpec {
const extra = parseExtra(row.extra);
return {
id: row.id,
provider: row.provider,
authKind: row.auth_kind,
baseUrl: row.base_url,
model: row.default_model,
thinkingLevel: row.thinking_level,
extra,
};
}
export function parseExtra(v: unknown): PiUpstreamExtra {
const r = PiUpstreamExtra.safeParse(v ?? {});
return r.success ? r.data : {};
}
function decode(authKind: string, plaintext: string): UpstreamCredential | undefined {
if (authKind === "oauth") {
try {
const v = JSON.parse(plaintext) as UpstreamCredential;
return v && typeof v === "object" && v.type === "oauth" ? v : undefined;
} catch {
return undefined;
}
}
return plaintext ? { type: "api_key", key: plaintext } : undefined;
}
async function readSecret(ctx: Ctx, row: Pick<PiUpstreamRow, "auth_kind" | "secret_ref">) {
if (!row.secret_ref) return undefined;
try {
return decode(row.auth_kind, await ctx.secrets.resolve(row.secret_ref));
} catch (err) {
ctx.logger.warn({ secretRef: row.secret_ref, err: { message: (err as Error).message } }, "上游凭据读取失败");
return undefined;
}
}
/** 该上游的凭据存储(读写行锁;明文只在返回值里) */
export function dbCredentialStore(ctx: Ctx, upstreamId: string): UpstreamCredentialStore {
return {
async read() {
const row = await ctx.db
.selectFrom("pi_upstream")
.select(["auth_kind", "secret_ref"])
.where("id", "=", upstreamId)
.executeTakeFirst();
return row ? await readSecret(ctx, row) : undefined;
},
async modify(fn) {
let created: string | null = null;
try {
return await ctx.db.transaction().execute(async (trx) => {
const row = await trx
.selectFrom("pi_upstream")
.select(["auth_kind", "secret_ref"])
.where("id", "=", upstreamId)
.forUpdate()
.executeTakeFirst();
if (!row) throw new Error("上游不存在或已删除");
const cur = await readSecret(ctx, row);
const next = await fn(cur);
if (next === undefined) return cur;
const plaintext = next.type === "oauth" ? JSON.stringify(next) : (next.key ?? "");
let ref = row.secret_ref;
if (ref) await ctx.secrets.replace(ref, plaintext);
else {
ref = await ctx.secrets.create({ purpose: "llm_api_key", plaintext });
created = ref;
}
const expires =
next.type === "oauth" && Number.isFinite(next.expires) && next.expires < NEVER_EXPIRES_MS
? new Date(next.expires)
: null;
await trx
.updateTable("pi_upstream")
.set({ secret_ref: ref, status: "ok", last_error: null, oauth_expires_at: expires })
.where("id", "=", upstreamId)
.execute();
return next;
});
} catch (err) {
if (created) await ctx.secrets.remove(created).catch(() => {});
throw err;
}
},
async delete() {
const ref = await ctx.db.transaction().execute(async (trx) => {
const row = await trx
.selectFrom("pi_upstream")
.select(["secret_ref"])
.where("id", "=", upstreamId)
.forUpdate()
.executeTakeFirst();
if (!row) return null;
await trx
.updateTable("pi_upstream")
.set({ secret_ref: null, status: "auth_required", oauth_expires_at: null })
.where("id", "=", upstreamId)
.execute();
return row.secret_ref;
});
if (ref) await ctx.secrets.remove(ref).catch(() => {});
},
};
}
const mask = (key: string) => (key.length >= 12 ? `${key.slice(0, 4)}…${key.slice(-4)}` : "****");
function accountOf(c: UpstreamCredential | undefined): string | null {
if (c?.type !== "oauth") return null;
for (const k of ["email", "accountId", "account", "enterpriseUrl", "login"]) {
const v = c[k];
if (typeof v === "string" && v) return v.slice(0, 200);
}
return null;
}
const iso = (d: Date | null) => (d ? d.toISOString() : null);
export function rowToApi(row: PiUpstreamRow): PiUpstreamApiRow {
return {
id: row.id,
name: row.name,
provider: row.provider as PiUpstreamApiRow["provider"],
authKind: row.auth_kind,
baseUrl: row.base_url,
defaultModel: row.default_model,
thinkingLevel: row.thinking_level,
extra: parseExtra(row.extra),
enabled: row.enabled,
status: row.status,
lastError: row.last_error,
lastVerifiedAt: iso(row.last_verified_at),
oauthExpiresAt: iso(row.oauth_expires_at),
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
/** API 视图:掩码 / 账号信息现场解密得到,明文不出本函数 */
export async function toView(ctx: Ctx, row: PiUpstreamRow, defaultId?: string | null): Promise<PiUpstream> {
const def = defaultId === undefined ? await defaultUpstreamId(ctx) : defaultId;
const cred = await readSecret(ctx, row);
return {
...rowToApi(row),
hasCredential: !!cred,
credentialMask: cred?.type === "api_key" && cred.key ? mask(cred.key) : null,
account: accountOf(cred),
isDefault: def === row.id,
};
}
// ── 默认上游(settings.pi.defaultUpstreamId)──
export async function defaultUpstreamId(ctx: Pick<ServerContext, "db">): Promise<string | null> {
const r = await sql<{
id: string | null;
}>`select value->>'defaultUpstreamId' as id from settings where key = 'pi'`.execute(ctx.db);
return r.rows[0]?.id ?? null;
}
/** 改默认上游:只动 settings.pi 的 defaultUpstreamId 一个字段;广播 settings.changed 让各进程缓存失效 */
export async function setDefaultUpstream(ctx: Ctx, id: string | null): Promise<void> {
const patch = JSON.stringify({ defaultUpstreamId: id });
await sql`insert into settings (key, value, updated_at)
values ('pi', ${JSON.stringify({ ...DEFAULT_SETTINGS.pi, defaultUpstreamId: id })}::jsonb, now())
on conflict (key) do update set value = settings.value || ${patch}::jsonb, updated_at = now()`.execute(
ctx.db,
);
const s = ctx.settings as { invalidate?: () => void };
s.invalidate?.();
void ctx.events.publish(SETTINGS_CHANGED, { keys: ["pi"] });
}
/** 状态更新(测试/调用结果);不碰凭据 */
export async function setStatus(
ctx: Pick<ServerContext, "db">,
id: string,
patch: { status: PiUpstreamRow["status"]; lastError?: string | null; verified?: boolean },
): Promise<void> {
await ctx.db
.updateTable("pi_upstream")
.set({
status: patch.status,
last_error: patch.lastError ?? null,
...(patch.verified ? { last_verified_at: new Date() } : {}),
})
.where("id", "=", id)
.execute();
}