- slot: new manual_verify event (attention -> verifying, with reason); a
successful organize of a download whose slot is in attention goes back
through verifying and lands by verify_passed on the actually imported
assets; the download's organize_blocked decision is resolved with
resolution {key: manual_organize | organize_retry, organizationRecordId} (U1)
- acquisition: plan purpose is upgrade only when the slot already has a
current asset; a movie slot gap is a fill (U7)
- identity: entities created from TMDB details get metadata fields projected
in the same transaction; TMDB aliases deduped by normalized text, site-title
aliases skipped when the text already exists (U9)
- pi: auth-mode test of a custom Base URL no longer marks the upstream ok, and
says to use the minimal completion to probe the endpoint (U2)
- sites: PUT /sites/{id}/credentials replaces same-kind secrets in place and
names new secrets with site id + random suffix (secret.name is unique; was a
500 on update); M-Team user data uses config/definition apiBaseUrl instead
of deriving api.<domain> from the site URL (Invalid URL on IP/local bases)
- copy: setup error says 初始化口令; backup job points at umt backup
- tests: schema expected tables add site_user_data; migration round-trip
timeout 60s; fake secrets honor label as name; vertical/resolve/pi/slot/
site-center tests for the above
- docs: 09-visual-qa W10-pre status table (U3 root cause: outbox events are
never mirrored to SSE)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
164 lines
7.1 KiB
TypeScript
164 lines
7.1 KiB
TypeScript
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 } : {}) });
|
|
// 自定义 Base URL 的凭证检查不连端点:不据此改写可用状态,提示用最小补全确认可达(QA U2)
|
|
const unprobed = mode === "auth" && !!row.base_url && row.auth_kind !== "oauth";
|
|
const notes = [
|
|
t.inCatalog ? null : `模型 ${t.model} 不在内置模型目录中,按手填 id 调用`,
|
|
unprobed ? "只核对了凭证与模型,没有连接 Base URL;用「最小补全」确认端点可达" : null,
|
|
].filter(Boolean);
|
|
out = { ok: true, source: t.source, model: t.model, errorKind: null, message: notes.join(";") || null };
|
|
if (!unprobed) 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))!);
|
|
});
|
|
}
|