@plico-ai/server 0.0.0-reserved.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +4 -0
- package/README.md +8 -3
- package/dist/api.d.ts +32 -0
- package/dist/api.js +87 -0
- package/dist/app/assemble.d.ts +10 -0
- package/dist/app/assemble.js +49 -0
- package/dist/app/errors.d.ts +6 -0
- package/dist/app/errors.js +24 -0
- package/dist/app/events.d.ts +9 -0
- package/dist/app/events.js +52 -0
- package/dist/app/metadata.d.ts +4 -0
- package/dist/app/metadata.js +24 -0
- package/dist/app/runs.d.ts +31 -0
- package/dist/app/runs.js +81 -0
- package/dist/app/scope.d.ts +8 -0
- package/dist/app/scope.js +18 -0
- package/dist/app/threads.d.ts +15 -0
- package/dist/app/threads.js +133 -0
- package/dist/app/types.d.ts +103 -0
- package/dist/app/types.js +1 -0
- package/dist/body.d.ts +4 -0
- package/dist/body.js +54 -0
- package/dist/create-plico.d.ts +18 -0
- package/dist/create-plico.js +75 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +19 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +3 -0
- package/dist/listener.d.ts +6 -0
- package/dist/listener.js +8 -0
- package/dist/mount.d.ts +32 -0
- package/dist/mount.js +34 -0
- package/dist/origin-guard.d.ts +15 -0
- package/dist/origin-guard.js +39 -0
- package/dist/project.d.ts +2 -0
- package/dist/project.js +10 -0
- package/dist/recovery.d.ts +19 -0
- package/dist/recovery.js +84 -0
- package/dist/responses.d.ts +1 -0
- package/dist/responses.js +6 -0
- package/dist/routes/artifacts.d.ts +2 -0
- package/dist/routes/artifacts.js +165 -0
- package/dist/routes/eval.d.ts +2 -0
- package/dist/routes/eval.js +38 -0
- package/dist/routes/health.d.ts +2 -0
- package/dist/routes/health.js +8 -0
- package/dist/routes/index.d.ts +11 -0
- package/dist/routes/index.js +72 -0
- package/dist/routes/metadata-query.d.ts +4 -0
- package/dist/routes/metadata-query.js +13 -0
- package/dist/routes/project.d.ts +2 -0
- package/dist/routes/project.js +23 -0
- package/dist/routes/readiness.d.ts +2 -0
- package/dist/routes/readiness.js +17 -0
- package/dist/routes/runs.d.ts +2 -0
- package/dist/routes/runs.js +82 -0
- package/dist/routes/threads.d.ts +2 -0
- package/dist/routes/threads.js +60 -0
- package/dist/routes/types.d.ts +30 -0
- package/dist/routes/types.js +1 -0
- package/dist/script.d.ts +8 -0
- package/dist/script.js +35 -0
- package/dist/serve-static.d.ts +2 -0
- package/dist/serve-static.js +37 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +25 -0
- package/dist/sse.d.ts +3 -0
- package/dist/sse.js +12 -0
- package/dist/stream.d.ts +13 -0
- package/dist/stream.js +58 -0
- package/dist/thread-failure.d.ts +7 -0
- package/dist/thread-failure.js +42 -0
- package/dist/thread-titles.d.ts +11 -0
- package/dist/thread-titles.js +37 -0
- package/dist/types.d.ts +12 -0
- package/dist/types.js +1 -0
- package/package.json +37 -8
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { buildArtifactWorkspace, readArtifactSnapshot, readArtifactVersion, } from "@plico-ai/runtime";
|
|
2
|
+
import { createTwoFilesPatch } from "diff";
|
|
3
|
+
import { requireRun } from "../app/runs.js";
|
|
4
|
+
import { HttpError } from "../errors.js";
|
|
5
|
+
import { jsonResponse } from "../responses.js";
|
|
6
|
+
async function readRunEvents(context, runId) {
|
|
7
|
+
const { eventStore, threadStore } = context;
|
|
8
|
+
await requireRun(context.app, runId);
|
|
9
|
+
const threadId = await threadStore?.getThreadForRun(runId);
|
|
10
|
+
if (!threadId)
|
|
11
|
+
return eventStore.getEvents(runId);
|
|
12
|
+
if (eventStore.getThreadRunEventsThrough)
|
|
13
|
+
return eventStore.getThreadRunEventsThrough(threadId, runId);
|
|
14
|
+
if (!eventStore.getThreadRunEvents)
|
|
15
|
+
throw new HttpError(503, "workspace.unavailable", "Thread workspace projection is unavailable.");
|
|
16
|
+
const events = await eventStore.getThreadRunEvents(threadId);
|
|
17
|
+
let lastRunEvent = -1;
|
|
18
|
+
events.forEach((event, index) => {
|
|
19
|
+
if (event.runId === runId)
|
|
20
|
+
lastRunEvent = index;
|
|
21
|
+
});
|
|
22
|
+
return lastRunEvent < 0 ? [] : events.slice(0, lastRunEvent + 1);
|
|
23
|
+
}
|
|
24
|
+
function requireWorkspace(runId, events) {
|
|
25
|
+
const workspace = buildArtifactWorkspace(runId, events);
|
|
26
|
+
if (!workspace) {
|
|
27
|
+
throw new HttpError(404, "workspace.not_found", `No artifact workspace for run: ${runId}`);
|
|
28
|
+
}
|
|
29
|
+
return workspace;
|
|
30
|
+
}
|
|
31
|
+
async function readWorkspace(context, runId) {
|
|
32
|
+
return requireWorkspace(runId, await readRunEvents(context, runId));
|
|
33
|
+
}
|
|
34
|
+
export const artifactRoutes = [
|
|
35
|
+
{
|
|
36
|
+
method: "GET",
|
|
37
|
+
pattern: /^\/v1\/runs\/([^/]+)\/workspace\/diffs$/,
|
|
38
|
+
handler: async (context) => {
|
|
39
|
+
const [runId = ""] = context.params;
|
|
40
|
+
const events = await readRunEvents(context, runId);
|
|
41
|
+
const workspace = requireWorkspace(runId, events);
|
|
42
|
+
const path = context.url.searchParams.get("path");
|
|
43
|
+
if (!path) {
|
|
44
|
+
throw new HttpError(400, "artifact.path_required", "A path is required to diff artifacts.");
|
|
45
|
+
}
|
|
46
|
+
if (!workspace.files.some((file) => file.path === path)) {
|
|
47
|
+
throw new HttpError(404, "artifact.not_found", `Unknown artifact file: ${path}`);
|
|
48
|
+
}
|
|
49
|
+
const fromVersion = readVersionQuery(context.url, "fromVersion");
|
|
50
|
+
const toVersion = readVersionQuery(context.url, "toVersion");
|
|
51
|
+
const from = readArtifactVersion(events, path, fromVersion);
|
|
52
|
+
const to = readArtifactVersion(events, path, toVersion);
|
|
53
|
+
if (!from || !to) {
|
|
54
|
+
throw new HttpError(404, "artifact.version_not_found", `Unknown artifact version for ${path}: ${!from ? fromVersion : toVersion}`);
|
|
55
|
+
}
|
|
56
|
+
if (typeof from.content !== "string" ||
|
|
57
|
+
typeof to.content !== "string" ||
|
|
58
|
+
!isDiffableMimeType(from.mimeType) ||
|
|
59
|
+
!isDiffableMimeType(to.mimeType)) {
|
|
60
|
+
throw new HttpError(415, "artifact.diff_unsupported_media_type", "Artifact diffs support text, JSON, and XML content.");
|
|
61
|
+
}
|
|
62
|
+
if (Buffer.byteLength(from.content, "utf8") > maxDiffContentBytes ||
|
|
63
|
+
Buffer.byteLength(to.content, "utf8") > maxDiffContentBytes) {
|
|
64
|
+
throw new HttpError(413, "artifact.diff_too_large", `Artifact versions must be at most ${maxDiffContentBytes} bytes to diff.`);
|
|
65
|
+
}
|
|
66
|
+
return jsonResponse(200, {
|
|
67
|
+
path,
|
|
68
|
+
fromVersion,
|
|
69
|
+
toVersion,
|
|
70
|
+
diff: createTwoFilesPatch(path, path, from.content, to.content, `version ${fromVersion}`, `version ${toVersion}`),
|
|
71
|
+
});
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
method: "GET",
|
|
76
|
+
pattern: /^\/v1\/runs\/([^/]+)\/workspace$/,
|
|
77
|
+
handler: async (context) => {
|
|
78
|
+
const runId = context.params[0] ?? "";
|
|
79
|
+
const workspace = await readWorkspace(context, runId);
|
|
80
|
+
const threadId = await context.threadStore?.getThreadForRun(runId);
|
|
81
|
+
return jsonResponse(200, threadId ? { ...workspace, threadId } : workspace);
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
method: "GET",
|
|
86
|
+
pattern: /^\/v1\/runs\/([^/]+)\/workspace\/files\/(.+)$/,
|
|
87
|
+
handler: async (context) => {
|
|
88
|
+
const [runId = "", path = ""] = context.params;
|
|
89
|
+
const workspace = await readWorkspace(context, runId);
|
|
90
|
+
const file = workspace.files.find((candidate) => candidate.path === path);
|
|
91
|
+
if (!file) {
|
|
92
|
+
throw new HttpError(404, "artifact.not_found", `Unknown artifact file: ${path}`);
|
|
93
|
+
}
|
|
94
|
+
return jsonResponse(200, file);
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
method: "GET",
|
|
99
|
+
pattern: /^\/v1\/runs\/([^/]+)\/workspace\/exports$/,
|
|
100
|
+
handler: async (context) => {
|
|
101
|
+
const [runId = ""] = context.params;
|
|
102
|
+
const workspace = await readWorkspace(context, runId);
|
|
103
|
+
const path = context.url.searchParams.get("path");
|
|
104
|
+
if (!path) {
|
|
105
|
+
throw new HttpError(400, "artifact.path_required", "A path is required to export an artifact.");
|
|
106
|
+
}
|
|
107
|
+
const file = workspace.files.find((candidate) => candidate.path === path);
|
|
108
|
+
if (!file) {
|
|
109
|
+
throw new HttpError(404, "artifact.not_found", `Unknown artifact file: ${path}`);
|
|
110
|
+
}
|
|
111
|
+
const filename = path.split("/").at(-1) || "artifact";
|
|
112
|
+
const encodedFilename = encodeURIComponent(filename).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
113
|
+
return new Response(typeof file.content === "string" ? file.content : Buffer.from(file.content.data, "base64"), {
|
|
114
|
+
headers: {
|
|
115
|
+
"x-content-type-options": "nosniff",
|
|
116
|
+
"content-type": exportMediaType(file.mimeType),
|
|
117
|
+
"content-disposition": `attachment; filename*=UTF-8''${encodedFilename}`,
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
method: "GET",
|
|
124
|
+
pattern: /^\/v1\/runs\/([^/]+)\/workspace\/snapshots\/([^/]+)$/,
|
|
125
|
+
handler: async (context) => {
|
|
126
|
+
const [runId = "", snapshotId = ""] = context.params;
|
|
127
|
+
const events = await readRunEvents(context, runId);
|
|
128
|
+
requireWorkspace(runId, events);
|
|
129
|
+
const snapshot = readArtifactSnapshot(events, snapshotId);
|
|
130
|
+
if (!snapshot) {
|
|
131
|
+
throw new HttpError(404, "artifact.not_found", `Unknown artifact snapshot: ${snapshotId}`);
|
|
132
|
+
}
|
|
133
|
+
return jsonResponse(200, snapshot);
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
];
|
|
137
|
+
function exportMediaType(mimeType) {
|
|
138
|
+
const mediaType = mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
139
|
+
if (mediaType === "text/html" ||
|
|
140
|
+
mediaType === "application/xhtml+xml" ||
|
|
141
|
+
mediaType === "image/svg+xml" ||
|
|
142
|
+
/\/(?:x-)?(?:java|ecma)script$/.test(mediaType) ||
|
|
143
|
+
/\+(?:java|ecma)script$/.test(mediaType)) {
|
|
144
|
+
return "application/octet-stream";
|
|
145
|
+
}
|
|
146
|
+
return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(mediaType)
|
|
147
|
+
? mediaType
|
|
148
|
+
: "application/octet-stream";
|
|
149
|
+
}
|
|
150
|
+
function readVersionQuery(url, name) {
|
|
151
|
+
const value = url.searchParams.get(name);
|
|
152
|
+
if (!value || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
|
|
153
|
+
throw new HttpError(400, "artifact.invalid_version", `${name} must be a positive integer.`);
|
|
154
|
+
}
|
|
155
|
+
return Number(value);
|
|
156
|
+
}
|
|
157
|
+
const maxDiffContentBytes = 1024 * 1024;
|
|
158
|
+
function isDiffableMimeType(mimeType) {
|
|
159
|
+
const mediaType = mimeType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
|
|
160
|
+
return (mediaType.startsWith("text/") ||
|
|
161
|
+
mediaType === "application/json" ||
|
|
162
|
+
mediaType.endsWith("+json") ||
|
|
163
|
+
mediaType === "application/xml" ||
|
|
164
|
+
mediaType.endsWith("+xml"));
|
|
165
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { runEvals } from "@plico-ai/runtime";
|
|
2
|
+
import { HttpError } from "../errors.js";
|
|
3
|
+
import { jsonResponse } from "../responses.js";
|
|
4
|
+
async function readLatestReport(eventStore) {
|
|
5
|
+
const report = await eventStore.getLatestEvalReport();
|
|
6
|
+
if (!report) {
|
|
7
|
+
throw new HttpError(404, "eval.not_found", "No eval results yet. Run evals first.");
|
|
8
|
+
}
|
|
9
|
+
return report;
|
|
10
|
+
}
|
|
11
|
+
export const evalRoutes = [
|
|
12
|
+
{
|
|
13
|
+
method: "POST",
|
|
14
|
+
hostOnly: true,
|
|
15
|
+
pattern: /^\/v1\/eval$/,
|
|
16
|
+
handler: async ({ projectRoot, eventStore }) => jsonResponse(200, await runEvals(projectRoot, { eventStore })),
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
method: "GET",
|
|
20
|
+
hostOnly: true,
|
|
21
|
+
pattern: /^\/v1\/eval$/,
|
|
22
|
+
handler: async ({ eventStore }) => jsonResponse(200, await readLatestReport(eventStore)),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
method: "GET",
|
|
26
|
+
hostOnly: true,
|
|
27
|
+
pattern: /^\/v1\/eval\/([^/]+)$/,
|
|
28
|
+
handler: async ({ eventStore, params }) => {
|
|
29
|
+
const name = params[0] ?? "";
|
|
30
|
+
const report = await readLatestReport(eventStore);
|
|
31
|
+
const result = report.results.find((candidate) => candidate.name === name);
|
|
32
|
+
if (!result) {
|
|
33
|
+
throw new HttpError(404, "eval.not_found", `Unknown eval: ${name}`);
|
|
34
|
+
}
|
|
35
|
+
return jsonResponse(200, result);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AppDeps } from "../app/types.js";
|
|
2
|
+
interface HandleRequestOptions {
|
|
3
|
+
app: AppDeps;
|
|
4
|
+
/** Aborts when the host closes Plico, so open event streams end. */
|
|
5
|
+
stop: AbortSignal;
|
|
6
|
+
studioPath: string | undefined;
|
|
7
|
+
allowScript: boolean;
|
|
8
|
+
request: Request;
|
|
9
|
+
}
|
|
10
|
+
export declare function handleRequest(options: HandleRequestOptions): Promise<Response>;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { PlicoError, statusForCode } from "../app/errors.js";
|
|
2
|
+
import { errorMessage, errorResponse, HttpError } from "../errors.js";
|
|
3
|
+
import { artifactRoutes } from "./artifacts.js";
|
|
4
|
+
import { evalRoutes } from "./eval.js";
|
|
5
|
+
import { healthRoutes } from "./health.js";
|
|
6
|
+
import { projectRoutes } from "./project.js";
|
|
7
|
+
import { readinessRoutes } from "./readiness.js";
|
|
8
|
+
import { runRoutes } from "./runs.js";
|
|
9
|
+
import { threadRoutes } from "./threads.js";
|
|
10
|
+
const routes = [
|
|
11
|
+
...healthRoutes,
|
|
12
|
+
...projectRoutes,
|
|
13
|
+
...runRoutes,
|
|
14
|
+
...threadRoutes,
|
|
15
|
+
...artifactRoutes,
|
|
16
|
+
...evalRoutes,
|
|
17
|
+
...readinessRoutes,
|
|
18
|
+
];
|
|
19
|
+
export async function handleRequest(options) {
|
|
20
|
+
const { app, stop, studioPath, allowScript, request } = options;
|
|
21
|
+
const url = new URL(request.url);
|
|
22
|
+
try {
|
|
23
|
+
if (app.inspect && request.method !== "GET" && request.method !== "HEAD")
|
|
24
|
+
return errorResponse(403, "inspection.read_only", "This Plico server is inspection-only and does not change runs or threads.");
|
|
25
|
+
const pathMatches = routes.filter((route) => route.pattern.test(url.pathname) && !(app.scope && route.hostOnly));
|
|
26
|
+
const route = pathMatches.find((candidate) => candidate.method === request.method);
|
|
27
|
+
if (route) {
|
|
28
|
+
const match = route.pattern.exec(url.pathname);
|
|
29
|
+
const params = (match?.slice(1) ?? []).map(decodeParam);
|
|
30
|
+
return await route.handler({
|
|
31
|
+
app,
|
|
32
|
+
stop,
|
|
33
|
+
projectRoot: app.projectRoot,
|
|
34
|
+
eventStore: app.eventStore,
|
|
35
|
+
threadStore: app.threadStore,
|
|
36
|
+
provider: app.provider,
|
|
37
|
+
providerRecoveryKey: app.providerRecoveryKey,
|
|
38
|
+
providerAccountKey: app.providerAccountKey,
|
|
39
|
+
engine: app.engine,
|
|
40
|
+
studioPath,
|
|
41
|
+
allowScript,
|
|
42
|
+
request,
|
|
43
|
+
url,
|
|
44
|
+
params,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
if (pathMatches.length > 0) {
|
|
48
|
+
return errorResponse(405, "method.not_allowed", "Method not allowed.", {
|
|
49
|
+
allow: [...new Set(pathMatches.map((r) => r.method))].join(", "),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return errorResponse(404, "route.not_found", "Unknown route.");
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error instanceof HttpError) {
|
|
56
|
+
return errorResponse(error.status, error.code, error.message);
|
|
57
|
+
}
|
|
58
|
+
if (error instanceof PlicoError) {
|
|
59
|
+
return errorResponse(statusForCode(error.code), error.code, error.message);
|
|
60
|
+
}
|
|
61
|
+
console.error(`plico server error: ${errorMessage(error)}`);
|
|
62
|
+
return errorResponse(500, "server.error", "Internal server error.");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function decodeParam(value) {
|
|
66
|
+
try {
|
|
67
|
+
return decodeURIComponent(value);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new HttpError(400, "request.invalid_path", "Malformed percent-encoding in path.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { HttpError } from "../errors.js";
|
|
2
|
+
/** Reads the `metadata` query parameter, a URL-encoded JSON object, as a list filter. */
|
|
3
|
+
export function readMetadataFilter(url) {
|
|
4
|
+
const raw = url.searchParams.get("metadata");
|
|
5
|
+
if (raw === null)
|
|
6
|
+
return {};
|
|
7
|
+
try {
|
|
8
|
+
return { metadata: JSON.parse(raw) };
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
throw new HttpError(400, "request.invalid", "metadata must be a URL-encoded JSON object.");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { composeInstructions } from "@plico-ai/runtime";
|
|
2
|
+
import { loadProjectOrThrow } from "../project.js";
|
|
3
|
+
import { jsonResponse } from "../responses.js";
|
|
4
|
+
export const projectRoutes = [
|
|
5
|
+
{
|
|
6
|
+
method: "GET",
|
|
7
|
+
hostOnly: true,
|
|
8
|
+
pattern: /^\/v1\/project$/,
|
|
9
|
+
handler: async ({ projectRoot }) => {
|
|
10
|
+
const project = await loadProjectOrThrow(projectRoot);
|
|
11
|
+
return jsonResponse(200, { project });
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
method: "GET",
|
|
16
|
+
hostOnly: true,
|
|
17
|
+
pattern: /^\/v1\/instructions$/,
|
|
18
|
+
handler: async ({ projectRoot }) => {
|
|
19
|
+
const project = await loadProjectOrThrow(projectRoot);
|
|
20
|
+
return jsonResponse(200, await composeInstructions(project));
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { checkReadiness } from "@plico-ai/runtime";
|
|
2
|
+
import { jsonResponse } from "../responses.js";
|
|
3
|
+
export const readinessRoutes = [
|
|
4
|
+
{
|
|
5
|
+
method: "GET",
|
|
6
|
+
hostOnly: true,
|
|
7
|
+
pattern: /^\/v1\/readiness$/,
|
|
8
|
+
handler: async ({ projectRoot, eventStore, threadStore, studioPath }) => {
|
|
9
|
+
const report = await checkReadiness(projectRoot, {
|
|
10
|
+
eventStore,
|
|
11
|
+
...(threadStore ? { threadStore } : {}),
|
|
12
|
+
studioPath,
|
|
13
|
+
});
|
|
14
|
+
return jsonResponse(200, report);
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
];
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { cancelRun, listRunEvents, listRuns, requireRun, runDry, signalRun, startRun, } from "../app/runs.js";
|
|
2
|
+
import { readJsonBody } from "../body.js";
|
|
3
|
+
import { jsonResponse } from "../responses.js";
|
|
4
|
+
import { readScript } from "../script.js";
|
|
5
|
+
import { eventStreamResponse, parseAfterCursor } from "../stream.js";
|
|
6
|
+
import { readMetadataFilter } from "./metadata-query.js";
|
|
7
|
+
function readAfterCursor(url) {
|
|
8
|
+
const after = url.searchParams.get("after");
|
|
9
|
+
return after === null ? undefined : parseAfterCursor(after);
|
|
10
|
+
}
|
|
11
|
+
export const runRoutes = [
|
|
12
|
+
{
|
|
13
|
+
method: "POST",
|
|
14
|
+
pattern: /^\/v1\/runs$/,
|
|
15
|
+
handler: async ({ app, allowScript, request }) => {
|
|
16
|
+
const body = await readJsonBody(request);
|
|
17
|
+
const script = readScript(body, allowScript);
|
|
18
|
+
return jsonResponse(202, await startRun(app, {
|
|
19
|
+
// A script sent over HTTP comes from the caller, so its tool steps never run handlers.
|
|
20
|
+
...(script === undefined ? {} : { script, mockTools: true }),
|
|
21
|
+
metadata: body.metadata,
|
|
22
|
+
}));
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
method: "POST",
|
|
27
|
+
hostOnly: true,
|
|
28
|
+
pattern: /^\/v1\/runs\/dry$/,
|
|
29
|
+
handler: async ({ app, allowScript, request }) => {
|
|
30
|
+
const script = readScript(await readJsonBody(request), allowScript);
|
|
31
|
+
return jsonResponse(200, await runDry(app, script === undefined ? {} : { script, mockTools: true }));
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
method: "GET",
|
|
36
|
+
pattern: /^\/v1\/runs$/,
|
|
37
|
+
handler: async ({ app, url }) => jsonResponse(200, await listRuns(app, readMetadataFilter(url))),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
method: "GET",
|
|
41
|
+
pattern: /^\/v1\/runs\/([^/]+)$/,
|
|
42
|
+
handler: async ({ app, params }) => jsonResponse(200, await requireRun(app, params[0] ?? "")),
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
method: "GET",
|
|
46
|
+
pattern: /^\/v1\/runs\/([^/]+)\/events\/stream$/,
|
|
47
|
+
handler: async (context) => {
|
|
48
|
+
const lastEventId = context.request.headers.get("last-event-id");
|
|
49
|
+
const afterSequence = lastEventId === null || lastEventId === ""
|
|
50
|
+
? readAfterCursor(context.url)
|
|
51
|
+
: parseAfterCursor(lastEventId);
|
|
52
|
+
return eventStreamResponse({
|
|
53
|
+
app: context.app,
|
|
54
|
+
stop: context.stop,
|
|
55
|
+
request: context.request,
|
|
56
|
+
runId: context.params[0] ?? "",
|
|
57
|
+
afterSequence,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
method: "GET",
|
|
63
|
+
pattern: /^\/v1\/runs\/([^/]+)\/events$/,
|
|
64
|
+
handler: async ({ app, params, url }) => jsonResponse(200, await listRunEvents(app, params[0] ?? "", readAfterCursor(url))),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
method: "POST",
|
|
68
|
+
pattern: /^\/v1\/runs\/([^/]+)\/cancel$/,
|
|
69
|
+
handler: async ({ app, params }) => jsonResponse(200, { result: await cancelRun(app, params[0] ?? "") }),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
method: "POST",
|
|
73
|
+
pattern: /^\/v1\/runs\/([^/]+)\/signal$/,
|
|
74
|
+
handler: async ({ app, params, request }) => {
|
|
75
|
+
const runId = params[0] ?? "";
|
|
76
|
+
await requireRun(app, runId);
|
|
77
|
+
const body = await readJsonBody(request);
|
|
78
|
+
const name = typeof body.name === "string" ? body.name : "";
|
|
79
|
+
return jsonResponse(200, { result: await signalRun(app, runId, name, body.value) });
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
];
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { createThread, listThreads, renameThread, requireThread, sendMessage, threadMessages, threadWorkspace, } from "../app/threads.js";
|
|
2
|
+
import { readJsonBody, readOptionalJsonBody } from "../body.js";
|
|
3
|
+
import { HttpError } from "../errors.js";
|
|
4
|
+
import { jsonResponse } from "../responses.js";
|
|
5
|
+
import { readMetadataFilter } from "./metadata-query.js";
|
|
6
|
+
export const threadRoutes = [
|
|
7
|
+
{
|
|
8
|
+
method: "POST",
|
|
9
|
+
pattern: /^\/v1\/threads$/,
|
|
10
|
+
handler: async (ctx) => {
|
|
11
|
+
const { metadata } = await readOptionalJsonBody(ctx.request);
|
|
12
|
+
return jsonResponse(201, await createThread(ctx.app, { metadata }));
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
method: "GET",
|
|
17
|
+
pattern: /^\/v1\/threads$/,
|
|
18
|
+
handler: async (ctx) => jsonResponse(200, await listThreads(ctx.app, readMetadataFilter(ctx.url))),
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
method: "GET",
|
|
22
|
+
pattern: /^\/v1\/threads\/([^/]+)$/,
|
|
23
|
+
handler: async (ctx) => jsonResponse(200, await requireThread(ctx.app, ctx.params[0] ?? "")),
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
method: "PATCH",
|
|
27
|
+
pattern: /^\/v1\/threads\/([^/]+)$/,
|
|
28
|
+
handler: async (ctx) => {
|
|
29
|
+
const threadId = ctx.params[0] ?? "";
|
|
30
|
+
await requireThread(ctx.app, threadId);
|
|
31
|
+
const { title } = await readJsonBody(ctx.request);
|
|
32
|
+
return jsonResponse(200, await renameThread(ctx.app, threadId, title));
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
method: "GET",
|
|
37
|
+
pattern: /^\/v1\/threads\/([^/]+)\/workspace$/,
|
|
38
|
+
handler: async (ctx) => jsonResponse(200, await threadWorkspace(ctx.app, ctx.params[0] ?? "")),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
method: "GET",
|
|
42
|
+
pattern: /^\/v1\/threads\/([^/]+)\/messages$/,
|
|
43
|
+
handler: async (ctx) => jsonResponse(200, await threadMessages(ctx.app, ctx.params[0] ?? "")),
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
method: "POST",
|
|
47
|
+
pattern: /^\/v1\/threads\/([^/]+)\/messages$/,
|
|
48
|
+
handler: async (ctx) => {
|
|
49
|
+
const threadId = ctx.params[0] ?? "";
|
|
50
|
+
await requireThread(ctx.app, threadId);
|
|
51
|
+
const body = await readJsonBody(ctx.request);
|
|
52
|
+
if (body.startRun !== undefined && typeof body.startRun !== "boolean")
|
|
53
|
+
throw new HttpError(400, "message.invalid", "startRun must be a boolean.");
|
|
54
|
+
const message = await sendMessage(ctx.app, threadId, body.content, {
|
|
55
|
+
startRun: body.startRun !== false,
|
|
56
|
+
});
|
|
57
|
+
return jsonResponse(201, message);
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Engine, EventStore, ModelProvider, ThreadStore } from "@plico-ai/runtime";
|
|
2
|
+
import type { AppDeps } from "../app/types.js";
|
|
3
|
+
export interface RouteContext {
|
|
4
|
+
/** The shared layer the thread and run routes call. */
|
|
5
|
+
app: AppDeps;
|
|
6
|
+
/** Aborts when the host closes Plico, so open event streams end. */
|
|
7
|
+
stop: AbortSignal;
|
|
8
|
+
projectRoot: string;
|
|
9
|
+
eventStore: EventStore;
|
|
10
|
+
threadStore: ThreadStore | undefined;
|
|
11
|
+
provider: ModelProvider | undefined;
|
|
12
|
+
providerRecoveryKey: string | undefined;
|
|
13
|
+
/** Account-only identity used to resume queued runs after a model change. */
|
|
14
|
+
providerAccountKey: string | undefined;
|
|
15
|
+
engine: Engine | undefined;
|
|
16
|
+
studioPath: string | undefined;
|
|
17
|
+
/** Whether a request may carry a `script` that drives project tools without a model. */
|
|
18
|
+
allowScript: boolean;
|
|
19
|
+
request: Request;
|
|
20
|
+
url: URL;
|
|
21
|
+
/** Decoded capture groups from the route pattern, in order. */
|
|
22
|
+
params: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface Route {
|
|
25
|
+
method: "GET" | "POST" | "PATCH";
|
|
26
|
+
pattern: RegExp;
|
|
27
|
+
/** Project-level routes. A scoped mount does not serve them. */
|
|
28
|
+
hostOnly?: true;
|
|
29
|
+
handler: (context: RouteContext) => Promise<Response>;
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/script.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type DryRunScriptStep } from "@plico-ai/runtime";
|
|
2
|
+
export declare function normalizeScript(value: unknown): DryRunScriptStep[];
|
|
3
|
+
/**
|
|
4
|
+
* Reads the optional `script` of a run request, refusing it unless the server opted in. Scripted
|
|
5
|
+
* runs over HTTP never run tool handlers: each tool step must carry its `result`, and `live` steps
|
|
6
|
+
* are refused.
|
|
7
|
+
*/
|
|
8
|
+
export declare function readScript(body: Record<string, unknown>, allowScript: boolean): DryRunScriptStep[] | undefined;
|
package/dist/script.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { normalizeScript as normalize, ScriptValidationError, } from "@plico-ai/runtime";
|
|
2
|
+
import { HttpError } from "./errors.js";
|
|
3
|
+
export function normalizeScript(value) {
|
|
4
|
+
try {
|
|
5
|
+
return normalize(value);
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error instanceof ScriptValidationError) {
|
|
9
|
+
throw new HttpError(400, "request.invalid_body", error.message);
|
|
10
|
+
}
|
|
11
|
+
throw error;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Reads the optional `script` of a run request, refusing it unless the server opted in. Scripted
|
|
16
|
+
* runs over HTTP never run tool handlers: each tool step must carry its `result`, and `live` steps
|
|
17
|
+
* are refused.
|
|
18
|
+
*/
|
|
19
|
+
export function readScript(body, allowScript) {
|
|
20
|
+
if (body.script === undefined)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (!allowScript) {
|
|
23
|
+
throw new HttpError(403, "run.script_disabled", "Scripted runs are disabled on this server. Start it with allowScript to enable them.");
|
|
24
|
+
}
|
|
25
|
+
const script = normalizeScript(body.script);
|
|
26
|
+
const live = script.findIndex((step) => step.type === "tool.call" && step.live === true);
|
|
27
|
+
if (live !== -1) {
|
|
28
|
+
throw new HttpError(400, "request.invalid_body", `Script step ${live} is live. Scripted runs over HTTP never run tool handlers; give the step a "result" instead.`);
|
|
29
|
+
}
|
|
30
|
+
const unmocked = script.findIndex((step) => step.type === "tool.call" && !("result" in step));
|
|
31
|
+
if (unmocked !== -1) {
|
|
32
|
+
throw new HttpError(400, "request.invalid_body", `Script step ${unmocked} has no "result". Scripted runs over HTTP never run tool handlers; add the "result" the tool should return.`);
|
|
33
|
+
}
|
|
34
|
+
return script;
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, join, normalize, relative, resolve } from "node:path";
|
|
3
|
+
const MIME_TYPES = {
|
|
4
|
+
".html": "text/html; charset=utf-8",
|
|
5
|
+
".js": "application/javascript; charset=utf-8",
|
|
6
|
+
".css": "text/css; charset=utf-8",
|
|
7
|
+
".json": "application/json; charset=utf-8",
|
|
8
|
+
".png": "image/png",
|
|
9
|
+
".svg": "image/svg+xml",
|
|
10
|
+
".ico": "image/x-icon",
|
|
11
|
+
".woff2": "font/woff2",
|
|
12
|
+
};
|
|
13
|
+
/** Answers with the file at `pathname` under `root`, or undefined when there is no such file. */
|
|
14
|
+
export async function serveStaticFile(root, pathname) {
|
|
15
|
+
let decoded;
|
|
16
|
+
try {
|
|
17
|
+
decoded = decodeURIComponent(pathname);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
const safePath = normalize(join(resolve(root), decoded === "/" ? "index.html" : decoded));
|
|
23
|
+
const fromRoot = relative(resolve(root), safePath);
|
|
24
|
+
if (fromRoot.startsWith("..") || isAbsolute(fromRoot)) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
const content = await readFile(safePath);
|
|
29
|
+
const ext = safePath.substring(safePath.lastIndexOf("."));
|
|
30
|
+
return new Response(content, {
|
|
31
|
+
headers: { "content-type": MIME_TYPES[ext] ?? "application/octet-stream" },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
import type { ServePlicoOptions } from "./types.js";
|
|
3
|
+
/** `createPlico` plus an HTTP listener over its `/v1` API. Closing the server closes Plico. */
|
|
4
|
+
export declare function servePlico(options: ServePlicoOptions): Promise<Server>;
|