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,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))!);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user