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

+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))!);
});
}