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
52 changes: 42 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as http from 'http';
import * as https from 'https';
import type { RoutingDecision as CanonicalRoutingDecision } from '@bodanglin/verdict-contracts';
import { adaptRoutingDecision } from './adapters/contract-to-middleware';
import { enforceExecutionEnvelope } from './middleware/forwarder';

const UNSAFE_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);

Expand Down Expand Up @@ -327,7 +328,7 @@ export type OpenAIChatCompletionChunk = z.infer<typeof OpenAIChatCompletionChunk
export interface ProxyRequestLike {
body?: unknown;
headers?: Record<string, string | string[] | undefined>;
llmRouter?: { decision?: Partial<MiddlewareRoutingDecision> };
llmRouter?: { decision?: Partial<MiddlewareRoutingDecision>; executionEnvelope?: unknown };
}

export interface ProxyResponseLike {
Expand Down Expand Up @@ -364,6 +365,10 @@ export interface GatewayConfig {
transportAdapter?: TransportAdapterConfig;
decisionEndpoint?: string;
decisionTimeoutMs?: number;
/** Require a Core routing decision before forwarding. Defaults to `true`
* (fail closed). Set to `false` explicitly to enable heuristic routing
* for compatibility-only deployments. */
requireCoreDecision?: boolean;
}

export type TransportAdapterKind = 'openai-compatible' | 'omniroute-documented';
Expand Down Expand Up @@ -404,6 +409,8 @@ export class LlmGateNode {
private transportAdapter: NormalizedTransportAdapter;
private decisionEndpoint: string | null;
private decisionTimeoutMs: number;
/** Require a Core routing decision. Defaults to `true` (fail closed). */
private requireCoreDecision: boolean;
private autoDetectorRan = false;

private usageCache: Record<string, { at: number; data: any }> = {};
Expand All @@ -429,6 +436,7 @@ export class LlmGateNode {
this.decisionEndpoint =
config.decisionEndpoint || process.env.VERDICT_CORE_DECISION_ENDPOINT || null;
this.decisionTimeoutMs = config.decisionTimeoutMs ?? 2000;
this.requireCoreDecision = config.requireCoreDecision ?? true;
this.autoDetectDependencies();
}

Expand Down Expand Up @@ -757,7 +765,7 @@ export class LlmGateNode {
return true;
}

private async fetchCoreDecision(body: unknown): Promise<MiddlewareRoutingDecision | null> {
private async fetchCoreDecision(body: unknown): Promise<CanonicalRoutingDecision | null> {
if (!this.decisionEndpoint) return null;

const response = await fetch(this.decisionEndpoint, {
Expand All @@ -777,7 +785,7 @@ export class LlmGateNode {
if (route.availability === 'unavailable' || route.availability === 'denied') return null;
if (route.decision === 'denied' || route.decision === 'unavailable') return null;

return adaptRoutingDecision(canonical);
return canonical;
}

/**
Expand All @@ -788,12 +796,19 @@ export class LlmGateNode {
return async (req: any, res: any, next: any) => {
const start = Date.now();
try {
const coreDecision = await this.fetchCoreDecision(req.body);
if (this.decisionEndpoint && !coreDecision) {
return res.status(503).json({ error: 'Routing decision unavailable or denied.' });
}
if (coreDecision) {
req.llmRouter = { decision: { ...coreDecision, latencyMs: Date.now() - start } };
const canonical = await this.fetchCoreDecision(req.body);
if (!canonical) {
if (this.requireCoreDecision) {
return res.status(503).json({ error: 'Routing decision unavailable or denied.' });
}
// Compatibility path: allow heuristic routing only when explicitly opted out
} else {
const adapted = adaptRoutingDecision(canonical);
const envelope = (canonical as Record<string, unknown>).execution_envelope as unknown;
req.llmRouter = {
decision: { ...adapted, latencyMs: Date.now() - start },
executionEnvelope: envelope,
};
return next();
}

Expand All @@ -811,7 +826,7 @@ export class LlmGateNode {
};
next();
} catch (_err) {
if (this.decisionEndpoint) {
if (this.requireCoreDecision) {
return res.status(503).json({ error: 'Routing decision unavailable or denied.' });
}
req.llmRouter = {
Expand Down Expand Up @@ -1005,6 +1020,23 @@ export class LlmGateNode {
});
}

// Enforce Core execution envelope before any upstream fetch.
// Only enforced when a Core decision (and thus envelope) is available.
// When requireCoreDecision=false (compatibility path), no envelope exists and enforcement is skipped.
const envelope = req.llmRouter?.executionEnvelope;
if (envelope !== undefined) {
try {
enforceExecutionEnvelope(envelope, parsedRequest.data, { required: true });
} catch (err) {
if (err instanceof Error && 'code' in err) {
return res.status(403).json({ error: err.message, code: (err as any).code });
}
return res
.status(403)
.json({ error: 'Execution envelope validation failed', code: 'envelope_invalid' });
}
}

const ladder = await this.buildDynamicLadder(tier);
const requestBody = parsedRequest.data;
const isStream = requestBody.stream === true;
Expand Down
25 changes: 19 additions & 6 deletions tests/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,10 @@ const validChunk = {

function createApp() {
const app = express();
const gateway = new LlmGateNode('cc/claude-opus-4-8');
const gateway = new LlmGateNode({
primaryModel: 'cc/claude-opus-4-8',
requireCoreDecision: false,
});

app.use(express.json());
app.post(
Expand Down Expand Up @@ -176,7 +179,7 @@ describe('LlmGateNode', () => {
process.env = originalEnv;
});
const createGatewayForProxyTests = () => {
const gateway = new LlmGateNode({ apiKey: 'secret-token' });
const gateway = new LlmGateNode({ apiKey: 'secret-token', requireCoreDecision: false });
jest.spyOn(gateway as any, 'buildDynamicLadder').mockResolvedValue(['fallback-model']);
return gateway;
};
Expand Down Expand Up @@ -442,12 +445,12 @@ describe('LlmGateNode', () => {
});

it('normalizes documented transport adapter defaults', () => {
const defaultGateway = new LlmGateNode();
const defaultGateway = new LlmGateNode({ requireCoreDecision: false });
expect((defaultGateway as any).primaryModel).toBe('cc/claude-opus-4-8');
});

it('uses the filtered OmniRoute defaults without inventing credentials', () => {
const defaultGateway = new LlmGateNode();
const defaultGateway = new LlmGateNode({ requireCoreDecision: false });
expect((defaultGateway as any).baseUrl).toBe('http://127.0.0.1:20132/v1');
expect((defaultGateway as any).usageUrl).toBe('http://127.0.0.1:20132/api');
expect((defaultGateway as any).apiKey).toBe(
Expand All @@ -465,6 +468,7 @@ describe('LlmGateNode', () => {
usagePathTemplate: '/usage/{connectionId}',
timeoutMs: 1234,
},
requireCoreDecision: false,
});

expect((gateway as any).getProviderConnIds()).toEqual({ openai: 'conn-openai' });
Expand All @@ -491,6 +495,7 @@ describe('LlmGateNode', () => {
const gateway = new LlmGateNode({
apiKey: 'secret-token',
transportAdapter: { kind: 'omniroute-documented' },
requireCoreDecision: false,
});

const ids = await (gateway as any).discoverCapabilities(true);
Expand All @@ -511,6 +516,7 @@ describe('LlmGateNode', () => {

const gateway = new LlmGateNode({
transportAdapter: { kind: 'omniroute-documented' },
requireCoreDecision: false,
});

const ids = await (gateway as any).discoverCapabilities(true);
Expand All @@ -530,6 +536,7 @@ describe('LlmGateNode', () => {
const gateway = new LlmGateNode({
apiKey: 'bad-token',
transportAdapter: { kind: 'omniroute-documented' },
requireCoreDecision: false,
});

const ids = await (gateway as any).discoverCapabilities(true);
Expand All @@ -545,6 +552,7 @@ describe('LlmGateNode', () => {

const gateway = new LlmGateNode({
transportAdapter: { kind: 'omniroute-documented', timeoutMs: 10 },
requireCoreDecision: false,
});

await expect((gateway as any).discoverCapabilities(true)).resolves.toEqual([]);
Expand Down Expand Up @@ -579,6 +587,7 @@ describe('LlmGateNode', () => {
apiKey: 'secret-token',
providerConnIds: { openai: 'conn-openai' },
transportAdapter: { kind: 'omniroute-documented' },
requireCoreDecision: false,
});

await expect((gateway as any).modelHasHeadroom('openai/gpt-4o-mini')).resolves.toBe(false);
Expand All @@ -589,6 +598,7 @@ describe('LlmGateNode', () => {
const gateway = new LlmGateNode({
providerConnIds: { openai: 'conn-openai' },
transportAdapter: { kind: 'openai-compatible' },
requireCoreDecision: false,
});

await expect((gateway as any).getUsageForProvider('openai')).resolves.toBeNull();
Expand All @@ -597,7 +607,7 @@ describe('LlmGateNode', () => {

it('handles missing req.body gracefully by falling back to empty object serialization', async () => {
const app = express();
const gateway = new LlmGateNode('custom/model-1');
const gateway = new LlmGateNode({ primaryModel: 'custom/model-1', requireCoreDecision: false });

// Intentionally omit express.json() to leave req.body undefined
app.post(
Expand Down Expand Up @@ -689,7 +699,10 @@ describe('LlmGateNode', () => {
],
])('falls back to primary model (fail-open strategy) for %s', async (_name, setupFn) => {
const app = express();
const gateway = new LlmGateNode('cc/claude-opus-4-8');
const gateway = new LlmGateNode({
primaryModel: 'cc/claude-opus-4-8',
requireCoreDecision: false,
});

app.use(express.json());
app.use((req, res, next) => {
Expand Down
Loading