From 9bec26b41a85fb4e94fceab2bf43b364de18e104 Mon Sep 17 00:00:00 2001 From: Tat Dat Duong Date: Fri, 11 Apr 2025 16:40:40 +0200 Subject: [PATCH] feat(api): custom routes --- libs/langgraph-api/src/api/meta.mts | 9 ++ libs/langgraph-api/src/auth/custom.mts | 2 +- libs/langgraph-api/src/cli/spawn.mts | 18 +++ libs/langgraph-api/src/http/custom.mts | 15 +++ libs/langgraph-api/src/http/middleware.mts | 57 +++++++++ libs/langgraph-api/src/server.mts | 117 ++++++++++-------- libs/langgraph-api/tests/graphs/http.mts | 3 + .../tests/graphs/langgraph.http.json | 16 +++ libs/langgraph-cli/src/utils/config.mts | 21 ++++ 9 files changed, 205 insertions(+), 53 deletions(-) create mode 100644 libs/langgraph-api/src/api/meta.mts create mode 100644 libs/langgraph-api/src/http/custom.mts create mode 100644 libs/langgraph-api/src/http/middleware.mts create mode 100644 libs/langgraph-api/tests/graphs/http.mts create mode 100644 libs/langgraph-api/tests/graphs/langgraph.http.json diff --git a/libs/langgraph-api/src/api/meta.mts b/libs/langgraph-api/src/api/meta.mts new file mode 100644 index 0000000..7921ab1 --- /dev/null +++ b/libs/langgraph-api/src/api/meta.mts @@ -0,0 +1,9 @@ +import { Hono } from "hono"; + +const api = new Hono(); + +api.get("/info", (c) => c.json({ flags: { assistants: true, crons: false } })); + +api.get("/ok", (c) => c.json({ ok: true })); + +export default api; diff --git a/libs/langgraph-api/src/auth/custom.mts b/libs/langgraph-api/src/auth/custom.mts index 5103d2d..a17a739 100644 --- a/libs/langgraph-api/src/auth/custom.mts +++ b/libs/langgraph-api/src/auth/custom.mts @@ -12,7 +12,7 @@ import { declare module "hono" { interface ContextVariableMap { - auth: AuthContext | undefined; + auth?: AuthContext | undefined; } } diff --git a/libs/langgraph-api/src/cli/spawn.mts b/libs/langgraph-api/src/cli/spawn.mts index e781638..42ca673 100644 --- a/libs/langgraph-api/src/cli/spawn.mts +++ b/libs/langgraph-api/src/cli/spawn.mts @@ -13,6 +13,23 @@ export async function spawnServer( ui?: Record; ui_config?: { shared?: string[] }; auth?: { path?: string; disable_studio_auth?: boolean }; + http?: { + app?: string; + disable_assistants?: boolean; + disable_threads?: boolean; + disable_runs?: boolean; + disable_store?: boolean; + disable_meta?: boolean; + cors?: { + allow_origins?: string[]; + allow_methods?: string[]; + allow_headers?: string[]; + allow_credentials?: boolean; + allow_origin_regex?: string; + expose_headers?: string[]; + max_age?: number; + }; + }; }; env: NodeJS.ProcessEnv; hostUrl: string; @@ -61,6 +78,7 @@ For production use, please use LangGraph Cloud. ui: context.config.ui, ui_config: context.config.ui_config, cwd: options.projectCwd, + http: context.config.http, }), ], { diff --git a/libs/langgraph-api/src/http/custom.mts b/libs/langgraph-api/src/http/custom.mts new file mode 100644 index 0000000..7f034b8 --- /dev/null +++ b/libs/langgraph-api/src/http/custom.mts @@ -0,0 +1,15 @@ +import { Hono } from "hono"; +import * as path from "node:path"; +import * as url from "node:url"; + +export async function registerHttp(appPath: string, options: { cwd: string }) { + const [userFile, exportSymbol] = appPath.split(":", 2); + const sourceFile = path.resolve(options.cwd, userFile); + + const user = (await import(url.pathToFileURL(sourceFile).toString()).then( + (module) => module[exportSymbol || "default"], + )) as Hono | undefined; + + if (!user) throw new Error(`Failed to load HTTP app: ${appPath}`); + return { api: user }; +} diff --git a/libs/langgraph-api/src/http/middleware.mts b/libs/langgraph-api/src/http/middleware.mts new file mode 100644 index 0000000..139e5dd --- /dev/null +++ b/libs/langgraph-api/src/http/middleware.mts @@ -0,0 +1,57 @@ +import { MiddlewareHandler } from "hono"; +import { cors as honoCors } from "hono/cors"; + +export const cors = ( + cors: + | { + allow_origins?: string[]; + allow_origin_regex?: string; + allow_methods?: string[]; + allow_headers?: string[]; + allow_credentials?: boolean; + expose_headers?: string[]; + max_age?: number; + } + | undefined, +): MiddlewareHandler => { + if (cors == null) return honoCors(); + + const originRegex = cors.allow_origin_regex + ? new RegExp(cors.allow_origin_regex) + : undefined; + + const origin = originRegex + ? (origin: string) => { + originRegex.lastIndex = 0; // reset regex in case it's a global regex + if (originRegex.test(origin)) return origin; + return undefined; + } + : (cors.allow_origins ?? []); + + // TODO: handle `cors.allow_credentials` + return honoCors({ + origin, + maxAge: cors.max_age, + allowMethods: cors.allow_methods, + allowHeaders: cors.allow_headers, + credentials: cors.allow_credentials, + exposeHeaders: cors.expose_headers, + }); +}; + +// This is used to match the behavior of the original LangGraph API +// where the content-type is not being validated. Might be nice +// to warn about this in the future and throw an error instead. +export const ensureContentType = (): MiddlewareHandler => { + return async (c, next) => { + if ( + c.req.header("content-type")?.startsWith("text/plain") && + c.req.method !== "GET" && + c.req.method !== "OPTIONS" + ) { + c.req.raw.headers.set("content-type", "application/json"); + } + + await next(); + }; +}; diff --git a/libs/langgraph-api/src/server.mts b/libs/langgraph-api/src/server.mts index b3070b4..687070d 100644 --- a/libs/langgraph-api/src/server.mts +++ b/libs/langgraph-api/src/server.mts @@ -1,6 +1,5 @@ import { serve } from "@hono/node-server"; import { Hono } from "hono"; -import { cors } from "hono/cors"; import { registerFromEnv } from "./graph/load.mjs"; @@ -8,6 +7,7 @@ import runs from "./api/runs.mjs"; import threads from "./api/threads.mjs"; import assistants from "./api/assistants.mjs"; import store from "./api/store.mjs"; +import meta from "./api/meta.mjs"; import { truncate, conn as opsConn } from "./storage/ops.mjs"; import { zValidator } from "@hono/zod-validator"; @@ -18,54 +18,8 @@ import { checkpointer } from "./storage/checkpoint.mjs"; import { store as graphStore } from "./storage/store.mjs"; import { auth } from "./auth/custom.mjs"; import { registerAuth } from "./auth/index.mjs"; - -const app = new Hono(); - -// This is used to match the behavior of the original LangGraph API -// where the content-type is not being validated. Might be nice -// to warn about this in the future and throw an error instead. -app.use(async (c, next) => { - if ( - c.req.header("content-type")?.startsWith("text/plain") && - c.req.method !== "GET" && - c.req.method !== "OPTIONS" - ) { - c.req.raw.headers.set("content-type", "application/json"); - } - - await next(); -}); - -app.use(cors()); -app.use(requestLogger()); -app.get("/info", (c) => c.json({ flags: { assistants: true, crons: false } })); - -app.post( - "/internal/truncate", - zValidator( - "json", - z.object({ - runs: z.boolean().optional(), - threads: z.boolean().optional(), - assistants: z.boolean().optional(), - checkpointer: z.boolean().optional(), - store: z.boolean().optional(), - }), - ), - (c) => { - const { runs, threads, assistants, checkpointer, store } = - c.req.valid("json"); - - truncate({ runs, threads, assistants, checkpointer, store }); - return c.json({ ok: true }); - }, -); - -app.use(auth()); -app.route("/", assistants); -app.route("/", runs); -app.route("/", threads); -app.route("/", store); +import { registerHttp } from "./http/custom.mjs"; +import { cors, ensureContentType } from "./http/middleware.mjs"; export const StartServerSchema = z.object({ port: z.number(), @@ -81,6 +35,27 @@ export const StartServerSchema = z.object({ .optional(), ui: z.record(z.string()).optional(), ui_config: z.object({ shared: z.array(z.string()).optional() }).optional(), + http: z + .object({ + app: z.string().optional(), + disable_assistants: z.boolean().default(false), + disable_threads: z.boolean().default(false), + disable_runs: z.boolean().default(false), + disable_store: z.boolean().default(false), + disable_meta: z.boolean().default(false), + cors: z + .object({ + allow_origins: z.array(z.string()).optional(), + allow_methods: z.array(z.string()).optional(), + allow_headers: z.array(z.string()).optional(), + allow_credentials: z.boolean().optional(), + allow_origin_regex: z.string().optional(), + expose_headers: z.array(z.string()).optional(), + max_age: z.number().optional(), + }) + .optional(), + }) + .optional(), }); export async function startServer(options: z.infer) { @@ -99,21 +74,59 @@ export async function startServer(options: z.infer) { logger.info(`Registering graphs from ${options.cwd}`); await registerFromEnv(options.graphs, { cwd: options.cwd }); + const app = new Hono(); + if (options.auth?.path) { logger.info(`Loading auth from ${options.auth.path}`); await registerAuth(options.auth, { cwd: options.cwd }); + app.use(auth()); } - if (options.ui) { - logger.info(`Loading UI`); - const { api, registerGraphUi } = await import("./ui/load.mjs"); + if (options.http?.app) { + logger.info(`Loading HTTP app from ${options.http.app}`); + const { api } = await registerHttp(options.http.app, { cwd: options.cwd }); app.route("/", api); + } + app.use(cors(options.http?.cors)); + app.use(requestLogger()); + app.use(ensureContentType()); + + app.post( + "/internal/truncate", + zValidator( + "json", + z.object({ + runs: z.boolean().optional(), + threads: z.boolean().optional(), + assistants: z.boolean().optional(), + checkpointer: z.boolean().optional(), + store: z.boolean().optional(), + }), + ), + (c) => { + const { runs, threads, assistants, checkpointer, store } = + c.req.valid("json"); + + truncate({ runs, threads, assistants, checkpointer, store }); + return c.json({ ok: true }); + }, + ); + + if (!options.http?.disable_meta) app.route("/", meta); + if (!options.http?.disable_assistants) app.route("/", assistants); + if (!options.http?.disable_runs) app.route("/", runs); + if (!options.http?.disable_threads) app.route("/", threads); + if (!options.http?.disable_store) app.route("/", store); + + if (options.ui) { logger.info(`Registering UI from ${options.cwd}`); + const { api, registerGraphUi } = await import("./ui/load.mjs"); await registerGraphUi(options.ui, { cwd: options.cwd, config: options.ui_config, }); + app.route("/", api); } logger.info(`Starting ${options.nWorkers} workers`); diff --git a/libs/langgraph-api/tests/graphs/http.mts b/libs/langgraph-api/tests/graphs/http.mts new file mode 100644 index 0000000..1e4b745 --- /dev/null +++ b/libs/langgraph-api/tests/graphs/http.mts @@ -0,0 +1,3 @@ +import { Hono } from "hono"; + +export const app = new Hono().get("/info", (c) => c.json({ random: true })); diff --git a/libs/langgraph-api/tests/graphs/langgraph.http.json b/libs/langgraph-api/tests/graphs/langgraph.http.json new file mode 100644 index 0000000..aafd032 --- /dev/null +++ b/libs/langgraph-api/tests/graphs/langgraph.http.json @@ -0,0 +1,16 @@ +{ + "node_version": "20", + "graphs": { + "agent": "./agent.mts:graph", + "agent_simple": "./agent_simple.mts:graph" + }, + "ui": { + "agent": "./agent/ui.tsx" + }, + "http": { + "app": "./http.mts:app" + }, + "env": { + "LANGGRAPH_CONFIG": "{\"agent\": {\"configurable\": {\"model_name\": \"openai\"}}}" + } +} diff --git a/libs/langgraph-cli/src/utils/config.mts b/libs/langgraph-cli/src/utils/config.mts index 6fd156e..2ca053e 100644 --- a/libs/langgraph-cli/src/utils/config.mts +++ b/libs/langgraph-cli/src/utils/config.mts @@ -32,6 +32,27 @@ const BaseConfigSchema = z.object({ disable_studio_auth: z.boolean().default(false), }) .optional(), + http: z + .object({ + app: z.string().optional(), + disable_assistants: z.boolean().default(false), + disable_threads: z.boolean().default(false), + disable_runs: z.boolean().default(false), + disable_store: z.boolean().default(false), + disable_meta: z.boolean().default(false), + cors: z + .object({ + allow_origins: z.array(z.string()).optional(), + allow_methods: z.array(z.string()).optional(), + allow_headers: z.array(z.string()).optional(), + allow_credentials: z.boolean().optional(), + allow_origin_regex: z.string().optional(), + expose_headers: z.array(z.string()).optional(), + max_age: z.number().optional(), + }) + .optional(), + }) + .optional(), }); const DEFAULT_PYTHON_VERSION = "3.11" as const;