char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somallow project reorderingb4fc6cf

main
27.7 KiB688 linesraw
1import { timingSafeEqual } from "node:crypto";
2import { decodeBase64, encodeBase64 } from "@std/encoding/base64";
3import {
4  MAX_IMAGE_BYTES,
5  MAX_IMAGES,
6  MAX_MESSAGE_BYTES,
7  validateImage,
8} from "@berry/api/images.ts";
9import type { JsonValue } from "@earendil-works/chord";
10import { BACKGROUND_CONTEXT as context, withAbortSignal } from "@earendil-works/chord/context";
11import {
12  type CredentialStore,
13  getSupportedThinkingLevels,
14  type Models,
15  type Static,
16  type TSchema,
17  Type,
18} from "@earendil-works/pi-ai";
19import {
20  ConversationBusy,
21  type EntryId,
22  InboxDoc,
23  type InboxItem,
24  type SubmissionId,
25} from "@earendil-works/pi-durable";
26import { Value } from "typebox/value";
27import { type Berry, BerryError, Conversations, type Host, Settings } from "./berry.ts";
28import { ProjectOrder, Projects } from "./projects.ts";
29import { SystemPromptPatch } from "./prompts.ts";
30import { createLogins } from "./logins.ts";
31import type { Loader } from "./loader.ts";
32import type { ExtensionWeb } from "./web.ts";
33import { type Feed, servePubsub } from "./pubsub.ts";
34import { serveChannel } from "./channel.ts";
35import { TID_PATTERN } from "./tid.ts";
36import { summaries } from "./summaries.ts";
37import { conversationTree } from "./tree.ts";
38import { references } from "./references.ts";
39
40class HttpError extends Error {
41  constructor(readonly status: number, message: string) {
42    super(message);
43  }
44}
45
46async function body<T extends TSchema>(
47  request: Request,
48  schema: T,
49  maxBytes?: number,
50): Promise<Static<T>> {
51  let value: unknown;
52  try {
53    if (maxBytes === undefined) value = await request.json();
54    else {
55      const chunks: Uint8Array[] = [];
56      let size = 0;
57      const reader = request.body?.getReader();
58      if (!reader) throw new HttpError(400, "expected a json body");
59      try {
60        while (true) {
61          const { value: chunk, done } = await reader.read();
62          if (done) break;
63          size += chunk.length;
64          if (size > maxBytes) {
65            await reader.cancel();
66            throw new HttpError(413, "Message exceeds the 30 MiB request limit");
67          }
68          chunks.push(chunk);
69        }
70      } finally {
71        reader.releaseLock();
72      }
73      const bytes = new Uint8Array(size);
74      let offset = 0;
75      for (const chunk of chunks) {
76        bytes.set(chunk, offset);
77        offset += chunk.length;
78      }
79      value = JSON.parse(new TextDecoder().decode(bytes));
80    }
81  } catch (error) {
82    if (error instanceof HttpError) throw error;
83    throw new HttpError(400, "expected a json body");
84  }
85  const [error] = Value.Errors(schema, value);
86  if (error) throw new HttpError(400, `${error.instancePath || "body"}: ${error.message}`);
87  return value as Static<T>;
88}
89
90const ModelRef = Type.Object({ provider: Type.String(), modelId: Type.String() });
91const ThinkingLevel = Type.Union([
92  Type.Literal("off"),
93  Type.Literal("minimal"),
94  Type.Literal("low"),
95  Type.Literal("medium"),
96  Type.Literal("high"),
97  Type.Literal("xhigh"),
98  Type.Literal("max"),
99]);
100const UserContent = Type.Union([
101  Type.String(),
102  Type.Array(Type.Union([
103    Type.Object({ type: Type.Literal("text"), text: Type.String() }),
104    Type.Object({ type: Type.Literal("image"), data: Type.String(), mimeType: Type.String() }),
105  ])),
106]);
107
108const Json = Type.Unsafe<JsonValue>(Type.Unknown());
109
110const CreateBody = Type.Object({
111  /** Each selected extension's configuration values. */
112  extensions: Type.Record(Type.String(), Type.Record(Type.String(), Json)),
113  model: Type.Optional(ModelRef),
114  thinkingLevel: Type.Optional(ThinkingLevel),
115});
116const ReadBody = Type.Object({ entryId: Type.Integer({ minimum: 1 }) });
117const ProjectBody = Type.Object({
118  name: Type.String({ minLength: 1, maxLength: 200 }),
119  roots: Type.Array(
120    Type.Object({
121      environment: Type.String({ minLength: 1 }),
122      cwd: Type.String({ maxLength: 4096 }),
123    }),
124    { minItems: 1, maxItems: 100 },
125  ),
126  defaultRoot: Type.Integer({ minimum: 0 }),
127  moveConversations: Type.Optional(Type.Boolean()),
128});
129const MessageBody = Type.Object({
130  content: UserContent,
131  whenBusy: Type.Optional(
132    Type.Union([Type.Literal("steer"), Type.Literal("followUp"), Type.Literal("reject")]),
133  ),
134  requestId: Type.Optional(Type.String()),
135});
136const EventBody = Type.Object({ ext: Type.String(), event: Json });
137const QueuedInputBody = Type.Object({ text: Type.String() }, { additionalProperties: false });
138const ConfigurationBody = Type.Record(Type.String(), Json);
139/** Without an entry, the fork continues from the newest: a copy of the conversation as it is. */
140const ForkBody = Type.Object({ entryId: Type.Optional(Type.Integer()) });
141/** Where in any of its branches a conversation continues from. */
142const BranchBody = Type.Object({ entryId: Type.Integer() });
143const CompactBody = Type.Object({ instructions: Type.Optional(Type.String()) });
144const ResetBody = Type.Object({ handoff: Type.Optional(Type.String()) });
145const CommandBody = Type.Object({ args: Type.Optional(Type.String()) });
146const RequestBody = Type.Object({ args: Type.Optional(Json) });
147const CHANNEL = new URLPattern({ pathname: "/api/conversations/:tid/extensions/:extension/:name" });
148const ShellBody = Type.Object({
149  command: Type.String(),
150  excludeFromContext: Type.Optional(Type.Boolean()),
151}, { additionalProperties: false });
152const SettingsBody = Type.Object({
153  allowLocalEnvironment: Type.Optional(Type.Boolean()),
154  systemPrompt: Type.Optional(SystemPromptPatch),
155  models: Type.Optional(Type.Array(ModelRef)),
156  environments: Type.Optional(
157    Type.Record(
158      Type.String(),
159      Type.Object({ ssh: Type.String(), deno: Type.Optional(Type.String()) }, {
160        additionalProperties: false,
161      }),
162    ),
163  ),
164});
165const LoginBody = Type.Object({
166  type: Type.Union([Type.Literal("api_key"), Type.Literal("oauth")]),
167});
168const AnswerBody = Type.Object({ prompt: Type.Integer(), value: Type.String() });
169const AgentBody = Type.Object({
170  model: Type.Optional(Type.Union([ModelRef, Type.Null()])),
171  thinkingLevel: Type.Optional(Type.Union([ThinkingLevel, Type.Null()])),
172  extensions: Type.Optional(Type.Array(Type.String())),
173});
174
175const ConversationBody = Type.Object({
176  archived: Type.Optional(Type.Boolean()),
177  title: Type.Optional(Type.Union([Type.String(), Type.Null()])),
178});
179
180type Handler = (params: Record<string, string>, request: Request) => unknown;
181
182export type HandlerOptions = {
183  readonly berry: Berry;
184  readonly models: Models;
185  /** Where `models` keeps logins: a provider is logged out by deleting its credential. */
186  readonly credentials: CredentialStore;
187  /** The extensions' built frontends. */
188  readonly web: ExtensionWeb;
189  /** Where the extensions come from, and how their loads went. */
190  readonly loader: Loader;
191  /** The process, for restarting it; absent where nothing can. */
192  readonly host?: Host;
193  readonly token: string;
194  /** Answers everything outside `/api` and `/ws`, such as the built frontend. */
195  readonly fallback?: (request: Request) => Promise<Response>;
196};
197
198/** How long a response must be before it is worth compressing. */
199const COMPRESS_AT = 1024;
200
201export function createHandler({
202  berry,
203  models,
204  credentials,
205  web,
206  loader,
207  host,
208  token,
209  fallback = () => Promise.resolve(new Response("not found", { status: 404 })),
210}: HandlerOptions) {
211  const logins = createLogins(models);
212  const expected = new TextEncoder().encode(token);
213  const authorize = (candidate: string) => {
214    const given = new TextEncoder().encode(candidate);
215    return given.length === expected.length && timingSafeEqual(given, expected);
216  };
217
218  async function conversation(tid: string) {
219    const found = TID_PATTERN.test(tid) ? await berry.conversation(tid) : undefined;
220    if (found === undefined) throw new HttpError(404, `no conversation ${tid}`);
221    return found;
222  }
223
224  /** An entry of any of `tid`'s branches, or without one the newest of the branch it continues on. */
225  async function entryOf(tid: string, id?: number) {
226    const head = await conversation(tid);
227    if (id !== undefined) {
228      const found = await berry.locate(tid, id);
229      if (found === undefined) throw new HttpError(400, `no entry ${id} in ${tid}`);
230      return { found, at: id as EntryId };
231    }
232    const [entry] = (await head.entries({ order: "descending" }, 1, undefined, context)).items;
233    if (entry === undefined) throw new HttpError(400, `${tid} has no entries`);
234    return { found: head, at: entry.id };
235  }
236
237  async function changeQueuedInput(tid: string, rawId: string, text: string | null) {
238    const id = Number(rawId);
239    if (!/^[1-9]\d*$/.test(rawId) || !Number.isSafeInteger(id)) {
240      throw new HttpError(400, "expected a positive safe integer submission id");
241    }
242    if (!TID_PATTERN.test(tid)) throw new HttpError(404, `no conversation ${tid}`);
243    const result = await berry.changeQueuedInput(tid, id as SubmissionId, text);
244    switch (result) {
245      case "ok":
246        return {};
247      case "not_found":
248        throw new HttpError(404, "no input submission in this conversation");
249      case "empty":
250        throw new HttpError(400, "A message needs text or an image attachment");
251      case "too_large":
252        throw new HttpError(413, "Message exceeds the 30 MiB limit including attachments");
253      default:
254        throw new HttpError(409, "Input is no longer queued");
255    }
256  }
257
258  // kept up from the first subscriber on
259  let summarized: ReturnType<typeof summaries> | undefined;
260
261  async function resolveTopic(topic: string): Promise<Feed | undefined> {
262    const [kind, tid, ext] = topic.split(":");
263    if (topic === "conversations") return await berry.harness.watchDoc(Conversations, context);
264    if (topic === "projects") return await berry.harness.watchDoc(Projects, context);
265    if (topic === "project-order") return await berry.harness.watchDoc(ProjectOrder, context);
266    if (topic === "settings") return await berry.harness.watchDoc(Settings, context);
267    if (topic === "extensions") return loader.feed();
268    if (topic === "summaries") return (await (summarized ??= summaries(berry))).feed();
269    if (kind === "conversation" && tid) {
270      return await berry.watch(tid);
271    }
272    if (kind === "state" && tid && ext) return await berry.watchState(tid, ext);
273    if (kind === "shell" && tid) return await berry.watchShell(tid);
274    if (kind === "configuration" && tid && ext) return await berry.watchConfiguration(tid, ext);
275    if (kind === "login" && tid) return logins.feed(tid);
276    return undefined;
277  }
278
279  const table: [method: string, path: string, handler: Handler][] = [
280    ["GET", "/api/projects", async () => await berry.harness.snapshot(Projects, context)],
281    ["POST", "/api/projects", async (_, request) => ({
282      id: await berry.saveProject(undefined, await body(request, ProjectBody)),
283    })],
284    ["PUT", "/api/project-order", async (_, request) => {
285      const { ids } = await body(request, Type.Object({ ids: Type.Array(Type.String()) }));
286      await berry.harness.commit(async (tx) => {
287        (await tx.doc(ProjectOrder)).ids = ids;
288      }, context);
289      return {};
290    }],
291    ["PUT", "/api/projects/:id", async ({ id }, request) => ({
292      id: await berry.saveProject(id, await body(request, ProjectBody)),
293    })],
294    ["DELETE", "/api/projects/:id", async ({ id }) => {
295      await berry.deleteProject(id);
296      return {};
297    }],
298    ["GET", "/api/folders", async (_, request) => {
299      const params = new URL(request.url).searchParams;
300      const environment = params.get("environment") ?? "local";
301      const prefix = params.get("prefix") ?? "";
302      const cwd = params.get("cwd");
303      if (prefix.length > 4096 || (cwd?.length ?? 0) > 4096) {
304        throw new HttpError(400, "Directory path is too long");
305      }
306      return await berry.folders(
307        environment,
308        cwd,
309        prefix,
310        withAbortSignal(request.signal, context),
311      );
312    }],
313    // a new conversation's composer completes references before the conversation exists
314    ["GET", "/api/references", async (_, request) => {
315      const params = new URL(request.url).searchParams;
316      const prefix = params.get("prefix") ?? "";
317      const cwd = params.get("cwd");
318      if (prefix.length > 4096 || (cwd?.length ?? 0) > 4096) {
319        throw new HttpError(400, "Path is too long");
320      }
321      return await berry.references(
322        params.get("environment") ?? "local",
323        cwd,
324        prefix,
325        withAbortSignal(request.signal, context),
326      );
327    }],
328    ["GET", "/api/conversations", async () => await berry.harness.snapshot(Conversations, context)],
329    [
330      "POST",
331      "/api/conversations",
332      async (_, request) => ({ tid: await berry.create(await body(request, CreateBody)) }),
333    ],
334    ["POST", "/api/conversations/:tid/read", async ({ tid }, request) => {
335      await conversation(tid);
336      const { entryId } = await body(request, ReadBody);
337      await berry.markRead(tid, entryId);
338      return {};
339    }],
340    ["PATCH", "/api/conversations/:tid/inbox/:id", async ({ tid, id }, request) => {
341      const { text } = await body(request, QueuedInputBody, MAX_MESSAGE_BYTES);
342      return await changeQueuedInput(tid, id, text);
343    }],
344    [
345      "DELETE",
346      "/api/conversations/:tid/inbox/:id",
347      async ({ tid, id }) => await changeQueuedInput(tid, id, null),
348    ],
349    ["POST", "/api/conversations/:tid/messages", async ({ tid }, request) => {
350      const { content, whenBusy, requestId } = await body(request, MessageBody, MAX_MESSAGE_BYTES);
351      const current = await conversation(tid);
352      const images = typeof content === "string"
353        ? []
354        : content.filter((block) => block.type === "image");
355      if (images.length > MAX_IMAGES) {
356        throw new HttpError(400, `A message can have at most ${MAX_IMAGES} images`);
357      }
358      for (const image of images) {
359        if (image.data.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4) {
360          throw new HttpError(413, "Images must be no larger than 5 MiB");
361        }
362        try {
363          const bytes = decodeBase64(image.data);
364          if (bytes.length > MAX_IMAGE_BYTES) {
365            throw new HttpError(413, "Images must be no larger than 5 MiB");
366          }
367          if (encodeBase64(bytes) !== image.data) {
368            throw new Error("Expected canonical base64 image data");
369          }
370          validateImage(bytes, image.mimeType);
371        } catch (error) {
372          if (error instanceof HttpError) throw error;
373          throw new HttpError(400, error instanceof Error ? error.message : "Invalid image");
374        }
375      }
376      if (images.length > 0) {
377        const { model: ref } = await current.agent(context);
378        const model = ref && models.getModel(ref.provider, ref.modelId);
379        if (!model?.input.includes("image")) {
380          throw new HttpError(
381            400,
382            "Choose a model that supports image input before sending images",
383          );
384        }
385      }
386      const submission = await current.submit({
387        type: "input",
388        content,
389        ...(whenBusy ? { whenBusy } : {}),
390        ...(requestId ? { requestId } : {}),
391      }, context);
392      return { submission: submission.id };
393    }],
394    ["POST", "/api/conversations/:tid/events", async ({ tid }, request) => {
395      const { ext, event } = await body(request, EventBody);
396      return { submission: (await berry.emit(tid, ext, event)).id };
397    }],
398    ["POST", "/api/conversations/:tid/shell", async ({ tid }, request) => {
399      await conversation(tid);
400      const { command, excludeFromContext } = await body(request, ShellBody);
401      return { task: await berry.shell(tid, command, excludeFromContext) };
402    }],
403    ["POST", "/api/conversations/:tid/abort", async ({ tid }) => {
404      const current = await conversation(tid);
405      let items: readonly InboxItem[] | undefined;
406      const inputs: InboxItem[] = [];
407      const stop = berry.harness.subscribeCommits(({ changes }) => {
408        const withdrawn = new Set(changes.flatMap((change) => {
409          if (change.type !== "submission") return [];
410          const record = change.value;
411          return record.conversationId === current.id && record.type === "input" &&
412              record.status === "unanswered" && record.reason === "aborted" &&
413              record.entry === undefined
414            ? [record.id]
415            : [];
416        }));
417        // use the revision before withdrawal, including edits and admissions that raced the request
418        for (const item of items ?? []) {
419          if (item.mode !== "write" && withdrawn.has(item.id)) inputs.push(item);
420        }
421        for (const change of changes) {
422          if (
423            change.type === "document" && change.conversationId === current.id &&
424            change.record.kind === InboxDoc.definition.kind
425          ) items = (change.value?.items ?? []) as InboxItem[];
426        }
427      });
428      try {
429        const initial = await berry.harness.snapshot(InboxDoc, current.id, context);
430        items ??= initial?.items ?? [];
431        await current.abort(context);
432        return { inputs };
433      } finally {
434        stop();
435      }
436    }],
437    ["POST", "/api/conversations/:tid/compact", async ({ tid }, request) => {
438      const { instructions } = await body(request, CompactBody);
439      return { task: await (await conversation(tid)).compact(instructions, context) };
440    }],
441    ["POST", "/api/conversations/:tid/reset", async ({ tid }, request) => {
442      const { handoff } = await body(request, ResetBody);
443      await (await conversation(tid)).reset(handoff, context);
444      return {};
445    }],
446    ["POST", "/api/conversations/:tid/fork", async ({ tid }, request) => {
447      const { entryId } = await body(request, ForkBody);
448      const { found, at } = await entryOf(tid, entryId);
449      const fork = await found.fork(at, { ownership: { kind: "ownerless" } }, context);
450      return { tid: await berry.tidOf(fork.id) };
451    }],
452    ["POST", "/api/conversations/:tid/branch", async ({ tid }, request) => {
453      const { entryId } = await body(request, BranchBody);
454      await conversation(tid);
455      await berry.branch(tid, entryId);
456      return {};
457    }],
458    ["POST", "/api/conversations/:tid/regenerate", async ({ tid }, request) => {
459      const { entryId } = await body(request, BranchBody);
460      await conversation(tid);
461      await berry.regenerate(tid, entryId);
462      return {};
463    }],
464    ["GET", "/api/conversations/:tid/tree", async ({ tid }) => {
465      await conversation(tid);
466      return await conversationTree(berry, tid);
467    }],
468    // what continuing from `at` would start with
469    ["GET", "/api/conversations/:tid/context", async ({ tid }, request) => {
470      const at = Number(new URL(request.url).searchParams.get("at"));
471      if (!Number.isInteger(at) || at <= 0) throw new HttpError(400, "expected ?at=<entry id>");
472      const { found } = await entryOf(tid, at);
473      const view = await found.context(context, { at: at as EntryId });
474      return { entries: view.entries };
475    }],
476    ["GET", "/api/conversations/:tid/references", async ({ tid }, request) => {
477      const params = new URL(request.url).searchParams;
478      const rawAt = params.get("at");
479      const at = rawAt === null ? undefined : Number(rawAt);
480      if (at !== undefined && (!Number.isInteger(at) || at <= 0)) {
481        throw new HttpError(400, "expected ?at=<entry id>");
482      }
483      const target = at === undefined ? await conversation(tid) : (await entryOf(tid, at)).found;
484      const agent = await target.agent(context);
485      if (!agent.extensions.some(({ name }) => name === "code")) return [];
486      const env = await berry.environment(tid, at as EntryId | undefined);
487      return await references(env, params.get("prefix") ?? "", context);
488    }],
489    ["PATCH", "/api/conversations/:tid/configuration/:name", async ({ tid, name }, request) => {
490      await conversation(tid);
491      return await berry.configureExtension(tid, name, await body(request, ConfigurationBody));
492    }],
493    ["GET", "/api/conversations/:tid/commands", async ({ tid }) => {
494      await conversation(tid);
495      return await berry.commands(tid);
496    }],
497    ["POST", "/api/conversations/:tid/commands/:name", async ({ tid, name }, request) => {
498      await conversation(tid);
499      const { args } = await body(request, CommandBody);
500      return { result: await berry.command(tid, name, args ?? "") ?? null };
501    }],
502    [
503      "POST",
504      "/api/conversations/:tid/extensions/:extension/:name",
505      async ({ tid, extension, name }, request) => {
506        await conversation(tid);
507        const { args } = await body(request, RequestBody);
508        // the browser gives up on a request by closing it, which ends the handler's work too
509        return { result: await berry.request(tid, extension, name, args, request.signal) };
510      },
511    ],
512    ["PATCH", "/api/conversations/:tid", async ({ tid }, request) => {
513      await conversation(tid);
514      const { archived, title } = await body(request, ConversationBody);
515      if (archived !== undefined) await berry.archive(tid, archived);
516      if (title !== undefined) await berry.rename(tid, title);
517      return {};
518    }],
519    ["PATCH", "/api/conversations/:tid/agent", async ({ tid }, request) => {
520      await conversation(tid);
521      await berry.configure(tid, await body(request, AgentBody));
522      return {};
523    }],
524    ["GET", "/api/providers", async () => {
525      const stored = new Set((await credentials.list()).map((credential) => credential.providerId));
526      return await Promise.all(
527        models.getProviders().map(async ({ id, name, auth }) => ({
528          id,
529          name,
530          apiKey: auth.apiKey?.login ? { name: auth.apiKey.name } : null,
531          oauth: auth.oauth
532            ? {
533              name: auth.oauth.name,
534              label: auth.oauth.loginLabel ?? null,
535              subscription: auth.oauth.isSubscription ?? false,
536            }
537            : null,
538          // how it is configured, if it is: a stored login, or something ambient such as an environment variable
539          auth: await models.checkAuth(id).catch(() => undefined) ?? null,
540          stored: stored.has(id),
541        })),
542      );
543    }],
544    ["POST", "/api/providers/:id/login", async ({ id }, request) => {
545      const { type } = await body(request, LoginBody);
546      return { login: logins.start(id, type) };
547    }],
548    ["POST", "/api/providers/:id/logout", async ({ id }) => {
549      await models.logout(id);
550      return {};
551    }],
552    ["POST", "/api/logins/:id/answer", async ({ id }, request) => {
553      const { prompt, value } = await body(request, AnswerBody);
554      logins.answer(id, prompt, value);
555      return {};
556    }],
557    ["POST", "/api/logins/:id/cancel", ({ id }) => {
558      logins.cancel(id);
559      return {};
560    }],
561    ["GET", "/api/settings", async () => await berry.harness.snapshot(Settings, context)],
562    ["PATCH", "/api/settings", async (_, request) => {
563      const { models, environments, allowLocalEnvironment, systemPrompt } = await body(
564        request,
565        SettingsBody,
566      );
567      if (allowLocalEnvironment !== undefined) {
568        await berry.setAllowLocalEnvironment(allowLocalEnvironment);
569      }
570      if (models) await berry.enableModels(models);
571      if (environments) await berry.setEnvironments(environments);
572      if (systemPrompt) await berry.setSystemPrompt(systemPrompt);
573      return await berry.harness.snapshot(Settings, context);
574    }],
575    ["GET", "/api/extensions", () => loader.entries()],
576    ["POST", "/api/extensions/:name/reload", async ({ name }) => await loader.reload(name)],
577    ["POST", "/api/restart", async () => {
578      if (host === undefined) throw new HttpError(404, "this berry cannot restart");
579      const failures = await host.check();
580      if (failures !== null) throw new HttpError(400, failures);
581      // after this response is on its way
582      setTimeout(() => host.restart(), 100);
583      return {};
584    }],
585    ["GET", "/api/models", async (_, request) => {
586      const refreshed = await models.refresh({ signal: request.signal });
587      for (const [id, error] of refreshed.errors) {
588        console.error(`[berry] refreshing ${id}:`, error);
589      }
590      return (await models.getAvailable(undefined, { signal: request.signal })).map((model) => ({
591        provider: model.provider,
592        id: model.id,
593        name: model.name,
594        input: model.input,
595        reasoning: model.reasoning,
596        thinkingLevels: getSupportedThinkingLevels(model),
597        contextWindow: model.contextWindow,
598        cost: model.cost,
599      }));
600    }],
601  ];
602  const routes = table.map(([method, path, handler]) =>
603    [method, new URLPattern({ pathname: path }), handler] as const
604  );
605
606  const sockets = new Set<WebSocket>();
607  /** Close every socket as a restart closes it; open sockets would hold a graceful shutdown. */
608  const closeSockets = () => {
609    for (const socket of sockets) socket.close(1012, "restarting");
610  };
611
612  return Object.assign(async (request: Request): Promise<Response> => {
613    const url = new URL(request.url);
614    if (url.pathname === "/ws") {
615      if (request.headers.get("upgrade") !== "websocket") {
616        return new Response("expected a websocket", { status: 426 });
617      }
618      const { socket, response } = Deno.upgradeWebSocket(request);
619      sockets.add(socket);
620      socket.addEventListener("close", () => sockets.delete(socket));
621      servePubsub(socket, { authorize, resolve: resolveTopic });
622      return response;
623    }
624
625    // frontend code, as public as the app's bundle; a dynamic import() could not send the token anyway
626    if (url.pathname.startsWith("/ext/")) {
627      return web.serve(url.pathname) ?? new Response("not found", { status: 404 });
628    }
629    if (!url.pathname.startsWith("/api/")) return await fallback(request);
630
631    // a browser cannot send a websocket its token as a header, so a channel authorizes with its first message
632    const channel = CHANNEL.exec(url)?.pathname.groups;
633    if (channel && request.headers.get("upgrade") === "websocket") {
634      const { tid, extension, name } = channel as Record<string, string>;
635      const { socket, response } = Deno.upgradeWebSocket(request);
636      sockets.add(socket);
637      socket.addEventListener("close", () => sockets.delete(socket));
638      serveChannel(socket, {
639        authorize,
640        open: (args, opened, signal) => berry.channel(tid, extension, name, args, opened, signal),
641      });
642      return response;
643    }
644
645    const bearer = request.headers.get("authorization")?.match(/^Bearer (.+)$/)?.[1];
646    if (bearer === undefined || !authorize(bearer)) {
647      return Response.json({ error: "unauthorized" }, { status: 401 });
648    }
649
650    for (const [method, pattern, handler] of routes) {
651      const match = pattern.exec(url);
652      if (match === null) continue;
653      if (request.method !== method) continue;
654      try {
655        const body = JSON.stringify(
656          await handler(match.pathname.groups as Record<string, string>, request) ?? null,
657        );
658        // a diff or a long history is mostly text, which a remote reader waits for byte by byte
659        if (
660          body.length < COMPRESS_AT ||
661          !/\bgzip\b/.test(request.headers.get("accept-encoding") ?? "")
662        ) {
663          return new Response(body, { headers: { "content-type": "application/json" } });
664        }
665        return new Response(new Blob([body]).stream().pipeThrough(new CompressionStream("gzip")), {
666          headers: {
667            "content-type": "application/json",
668            "content-encoding": "gzip",
669            vary: "accept-encoding",
670          },
671        });
672      } catch (error) {
673        if (error instanceof HttpError) {
674          return Response.json({ error: error.message }, { status: error.status });
675        }
676        if (error instanceof BerryError) {
677          return Response.json({ error: error.message }, { status: 400 });
678        }
679        if (error instanceof ConversationBusy) {
680          return Response.json({ error: error.message }, { status: 409 });
681        }
682        console.error(`[http] ${request.method} ${url.pathname}:`, error);
683        return Response.json({ error: "internal error" }, { status: 500 });
684      }
685    }
686    return Response.json({ error: "not found" }, { status: 404 });
687  }, { closeSockets });
688}