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:
1 parent
743fe2486d
commit
fd3ffe5a8d
13 files changed
+1617
-26
No files matched your search
@@ -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();
|
||||
Reference in new issue
Block a user