From 79aa2f86531846b1fb061e1e88474d33c7aa59f2 Mon Sep 17 00:00:00 2001 From: Nicholas Carter Date: Sun, 2 Aug 2026 11:25:27 +0000 Subject: [PATCH] feat: add Next API route compatibility --- README.md | 15 +++++++++++++- src/index.ts | 49 +++++++++++++++++++++++++++++++++++++++++++- tests/router.test.ts | 37 +++++++++++++++++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ed24e2e..e693626 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - Zod request/response schemas - Model catalog discovery from configured upstream - Bounded fallback ladder for selected HTTP/network failures +- Explicit fail-open behavior: inconclusive model discovery, missing quota/headroom data, unavailable usage APIs, and middleware classification errors keep routing on the safe primary/fallback path instead of blocking client traffic - Non-streaming SSE forwarding - In-memory score cache (process-local) @@ -58,7 +59,7 @@ pnpm add @verdict/node yarn add @verdict/node ``` -**Peer dependency**: `express@>=5.0.0 <6` +**Peer dependency**: `express@>=5.0.0 <6` only when using Express middleware. Next.js `/api` routes can use the generic handler without mounting Express. --- @@ -83,6 +84,18 @@ app.use( app.listen(3000, () => console.log('verdict-node listening on :3000')); ``` +Next.js `/api` route: + +```typescript +// pages/api/chat/completions.ts +import { createNextApiHandler } from '@verdict/node'; + +export default createNextApiHandler({ + baseUrl: process.env.OMNIROUTE_BASE_URL ?? 'http://127.0.0.1:20132/v1', + apiKey: process.env.OMNIROUTE_API_KEY, +}); +``` + ```bash # Start OmniRoute (if not running) docker run -d -p 20128:20128 omnibus/omniroute diff --git a/src/index.ts b/src/index.ts index 444f57f..52500cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -346,6 +346,19 @@ export interface ProxyResponseLike { export type ProxyNextFunction = (error?: unknown) => void; +export interface NextApiRequestLike extends ProxyRequestLike { + method?: string; +} + +export interface NextApiResponseLike extends ProxyResponseLike { + setHeader(name: string, value: number | string | string[]): void; +} + +export type NextApiHandlerLike = ( + req: NextApiRequestLike, + res: NextApiResponseLike +) => Promise; + export interface GatewayConfig { primaryModel?: string; baseUrl?: string; @@ -732,7 +745,7 @@ export class LlmGateNode { /** * Intercepts preliminary evaluations to log heuristic latency. - * @returns Express Request Handler. + * @returns Express-compatible request handler. */ public middleware() { return async (req: any, res: any, next: any) => { @@ -897,6 +910,34 @@ export class LlmGateNode { res.end(); } + /** + * Next.js /api route handler for POST /api/* style routes. + * Mirrors Express composition: evaluate routing metadata, then proxy. + */ + public nextApiHandler(): NextApiHandlerLike { + const middleware = this.middleware(); + const proxy = this.proxy(); + + return async (req, res) => { + if (req.method && req.method !== 'POST') { + res.setHeader('Allow', 'POST'); + res.status(405).json({ error: 'Method Not Allowed' }); + return; + } + + await middleware(req, res, (error: unknown) => { + if (error) { + throw error; + } + }); + await proxy(req, res, (error: unknown) => { + if (error) { + throw error; + } + }); + }; + } + /** * End-to-end Proxy and Streaming wrapper. * Constructs the Dynamic Route Ladder, validates live availability usage logic sequentially, @@ -970,3 +1011,9 @@ export class LlmGateNode { } export { LlmGateNode as LLMGateway }; + +export function createNextApiHandler( + configOrModel: string | GatewayConfig = {} +): NextApiHandlerLike { + return new LlmGateNode(configOrModel).nextApiHandler(); +} diff --git a/tests/router.test.ts b/tests/router.test.ts index 1468ee1..0c7e0e7 100644 --- a/tests/router.test.ts +++ b/tests/router.test.ts @@ -9,6 +9,7 @@ import { ProxyRequestLike, ProxyResponseLike, MiddlewareRoutingDecisionSchema, + createNextApiHandler, } from '../src'; const validRequest = { @@ -715,6 +716,42 @@ describe('LlmGateNode', () => { }); }); + describe('Next.js /api compatibility', () => { + it('handles a Next.js-like /api route without Express next()', async () => { + const handler = createNextApiHandler({ apiKey: 'secret-token' }); + jest.spyOn(globalThis, 'fetch').mockImplementation(async url => { + if (String(url).endsWith('/models')) { + return new Response(JSON.stringify({ data: [] }), { status: 200 }); + } + return new Response(JSON.stringify(validResponse), { status: 200 }); + }); + const recorder = createProxyResponseRecorder(); + + await handler( + { + method: 'POST', + body: validRequest, + headers: { accept: 'application/json' }, + }, + recorder.res + ); + + expect(recorder.statusCode).toBe(200); + expect(recorder.jsonPayload).toEqual(validResponse); + }); + + it('rejects non-POST Next.js-like /api requests', async () => { + const handler = createNextApiHandler(); + const recorder = createProxyResponseRecorder(); + + await handler({ method: 'GET', body: {}, headers: {} }, recorder.res); + + expect(recorder.statusCode).toBe(405); + expect(recorder.headers.get('Allow')).toBe('POST'); + expect(recorder.jsonPayload).toEqual({ error: 'Method Not Allowed' }); + }); + }); + describe('OpenAI chat completion request parser', () => { it('accepts a valid request', () => { expect(OpenAIChatCompletionRequestSchema.safeParse(validRequest).success).toBe(true);