Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.

---

Expand All @@ -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
Expand Down
49 changes: 48 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

export interface GatewayConfig {
primaryModel?: string;
baseUrl?: string;
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -970,3 +1011,9 @@ export class LlmGateNode {
}

export { LlmGateNode as LLMGateway };

export function createNextApiHandler(
configOrModel: string | GatewayConfig = {}
): NextApiHandlerLike {
return new LlmGateNode(configOrModel).nextApiHandler();
}
37 changes: 37 additions & 0 deletions tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ProxyRequestLike,
ProxyResponseLike,
MiddlewareRoutingDecisionSchema,
createNextApiHandler,
} from '../src';

const validRequest = {
Expand Down Expand Up @@ -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);
Expand Down