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