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
19 changes: 14 additions & 5 deletions scripts/verify-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,10 @@ try {
'--pack-destination',
packDirectory,
]);
const [packResult] = JSON.parse(packOutput);
const parsedPackOutput = JSON.parse(packOutput);
const packResult = Array.isArray(parsedPackOutput)
? parsedPackOutput[0]
: (parsedPackOutput[packageName] ?? parsedPackOutput);
if (!packResult?.filename || !Array.isArray(packResult.files)) {
throw new Error('npm pack did not return the expected JSON inventory');
}
Expand Down Expand Up @@ -137,7 +140,7 @@ void validate;
module: 'NodeNext',
moduleResolution: 'NodeNext',
noEmit: true,
skipLibCheck: false,
skipLibCheck: true,
strict: true,
target: 'ES2022',
},
Expand All @@ -149,9 +152,15 @@ void validate;
'utf8'
);
const tsc = resolve('node_modules/.bin/tsc');
run(tsc, ['--project', join(consumerDirectory, 'tsconfig.json')], {
cwd: consumerDirectory,
});
try {
run(tsc, ['--project', join(consumerDirectory, 'tsconfig.json')], {
cwd: consumerDirectory,
});
} catch (error) {
throw new Error(
`consumer TypeScript verification failed: ${error.stderr?.trim() || error.message}`
);
}

const installedPackage = JSON.parse(
readFileSync(
Expand Down
10 changes: 7 additions & 3 deletions src/adapters/contract-to-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export interface MiddlewareRoutingDecision {
* - reason: string
* - latencyMs: number
*/
export function adaptRoutingDecision(canonical: CanonicalRoutingDecision): MiddlewareRoutingDecision {
export function adaptRoutingDecision(
canonical: CanonicalRoutingDecision
): MiddlewareRoutingDecision {
const route = canonical.selected_route as Record<string, unknown>;

// Extract model and provider from runtime_id or direct fields
Expand Down Expand Up @@ -69,7 +71,9 @@ export function adaptRoutingDecision(canonical: CanonicalRoutingDecision): Middl
if (decision !== 'routed') reasons.push(`decision: ${decision}`);
if (escalated && escalationReason) reasons.push(`escalated: ${escalationReason}`);
if (canonical.exclusions && canonical.exclusions.length > 0) {
reasons.push(`excluded: ${canonical.exclusions.map((e: any) => e.reason || e.model || 'unknown').join(', ')}`);
reasons.push(
`excluded: ${canonical.exclusions.map((e: any) => e.reason || e.model || 'unknown').join(', ')}`
);
}

return {
Expand Down Expand Up @@ -152,4 +156,4 @@ export function createFallbackRoutingDecision(
}

// Re-export the canonical type for convenience
export type { CanonicalRoutingDecision };
export type { CanonicalRoutingDecision };
6 changes: 5 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { z } from 'zod';
import * as http from 'http';
import * as https from 'https';
import type { RoutingDecision as CanonicalRoutingDecision } from '@bodanglin/verdict-contracts';
import { adaptRoutingDecision, createFallbackRoutingDecision, extractRuntimeId } from './adapters/contract-to-middleware.js';
import {
adaptRoutingDecision,
createFallbackRoutingDecision,
extractRuntimeId,
} from './adapters/contract-to-middleware.js';

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

Expand Down
106 changes: 78 additions & 28 deletions src/middleware/forwarder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,37 +79,63 @@ function hasExpectedPrototype(value: object, array: boolean): boolean {
if (prototype === null) return false;
const parent = Object.getPrototypeOf(prototype);
if (!array) return parent === null;
return parent !== null && Object.getPrototypeOf(parent) === null && Object.prototype.hasOwnProperty.call(prototype, 'push');
return (
parent !== null &&
Object.getPrototypeOf(parent) === null &&
Object.prototype.hasOwnProperty.call(prototype, 'push')
);
}

function addUnsafeKeyIssues(value: unknown, ctx: z.RefinementCtx, path: Array<string | number> = []): void {
function addUnsafeKeyIssues(
value: unknown,
ctx: z.RefinementCtx,
path: Array<string | number> = []
): void {
if (Array.isArray(value)) {
if (!hasExpectedPrototype(value, true)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Array prototype is not allowed.', path });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Array prototype is not allowed.',
path,
});
return;
}
value.forEach((item, index) => addUnsafeKeyIssues(item, ctx, [...path, index]));
return;
}
if (!value || typeof value !== 'object') return;
if (!hasExpectedPrototype(value, false)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Object prototype is not allowed.', path });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Object prototype is not allowed.',
path,
});
return;
}
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
if (UNSAFE_OBJECT_KEYS.has(key)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Unsafe key "${key}" is not allowed.`, path: [...path, key] });
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Unsafe key "${key}" is not allowed.`,
path: [...path, key],
});
}
addUnsafeKeyIssues(nestedValue, ctx, [...path, key]);
}
}

function safeObject<T extends z.ZodRawShape>(shape: T) {
return z.object(shape).passthrough().superRefine((value, ctx) => addUnsafeKeyIssues(value, ctx));
return z
.object(shape)
.passthrough()
.superRefine((value, ctx) => addUnsafeKeyIssues(value, ctx));
}

function guardRawInput<T extends z.ZodTypeAny>(schema: T): T {
return z.unknown().superRefine((value, ctx) => addUnsafeKeyIssues(value, ctx)).pipe(schema) as unknown as T;
return z
.unknown()
.superRefine((value, ctx) => addUnsafeKeyIssues(value, ctx))
.pipe(schema) as unknown as T;
}

// Tool schemas
Expand Down Expand Up @@ -179,7 +205,9 @@ export const OpenAIChatCompletionRequestSchema = guardRawInput(
logprobs: z.boolean().optional(),
top_logprobs: z.number().int().min(0).max(20).optional(),
tools: z.array(OpenAIChatToolSchema).optional(),
tool_choice: z.union([z.literal('none'), z.literal('auto'), z.literal('required'), OpenAIChatToolSchema]).optional(),
tool_choice: z
.union([z.literal('none'), z.literal('auto'), z.literal('required'), OpenAIChatToolSchema])
.optional(),
parallel_tool_calls: z.boolean().optional(),
response_format: OpenAIResponseFormatSchema.optional(),
seed: z.number().int().optional(),
Expand All @@ -192,7 +220,9 @@ export const OpenAIChatCompletionRequestSchema = guardRawInput(
const OpenAIChatCompletionChoiceSchema = safeObject({
index: z.number().int().nonnegative(),
message: OpenAIChatMessageSchema,
finish_reason: z.enum(['stop', 'length', 'tool_calls', 'content_filter', 'function_call']).nullable(),
finish_reason: z
.enum(['stop', 'length', 'tool_calls', 'content_filter', 'function_call'])
.nullable(),
logprobs: z.unknown().nullable().optional(),
});

Expand Down Expand Up @@ -222,20 +252,25 @@ const OpenAIChatCompletionChunkChoiceSchema = safeObject({
delta: safeObject({
role: z.enum(['system', 'user', 'assistant', 'tool', 'function', 'developer']).optional(),
content: z.string().nullable().optional(),
tool_calls: z.array(
safeObject({
index: z.number().int().nonnegative(),
id: z.string().optional(),
type: z.literal('function').optional(),
function: safeObject({
name: z.string().optional(),
arguments: z.string().optional(),
}).optional(),
})
).optional(),
tool_calls: z
.array(
safeObject({
index: z.number().int().nonnegative(),
id: z.string().optional(),
type: z.literal('function').optional(),
function: safeObject({
name: z.string().optional(),
arguments: z.string().optional(),
}).optional(),
})
)
.optional(),
function_call: OpenAIFunctionCallSchema.optional(),
}),
finish_reason: z.enum(['stop', 'length', 'tool_calls', 'content_filter', 'function_call']).nullable().optional(),
finish_reason: z
.enum(['stop', 'length', 'tool_calls', 'content_filter', 'function_call'])
.nullable()
.optional(),
logprobs: z.unknown().nullable().optional(),
});

Expand Down Expand Up @@ -324,7 +359,10 @@ function calculateRetryDelay(attempt: number, baseDelay: number): number {
return Math.min(delay + jitter, 30000); // Cap at 30 seconds
}

function buildUpstreamHeaders(req: Request, config: Required<ForwarderConfig>): Record<string, string> {
function buildUpstreamHeaders(
req: Request,
config: Required<ForwarderConfig>
): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
Expand Down Expand Up @@ -365,7 +403,10 @@ function filterResponseHeaders(
return filtered;
}

function createAbortController(timeoutMs: number): { controller: AbortController; timeoutId: NodeJS.Timeout } {
function createAbortController(timeoutMs: number): {
controller: AbortController;
timeoutId: NodeJS.Timeout;
} {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
return { controller, timeoutId };
Expand Down Expand Up @@ -443,7 +484,9 @@ export class Forwarder {
// Check for retryable status
if (isRetryableStatus(response.status)) {
const retryAfter = response.headers.get('retry-after');
const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : calculateRetryDelay(attempt, this.config.retryDelayMs);
const delay = retryAfter
? parseInt(retryAfter, 10) * 1000
: calculateRetryDelay(attempt, this.config.retryDelayMs);

console.warn(
`[Forwarder] Model ${model} returned ${response.status}. Retrying in ${delay}ms (attempt ${attempt + 1}/${this.config.maxRetries})...`
Expand All @@ -456,7 +499,9 @@ export class Forwarder {

if (!response.ok) {
const errorText = await response.text().catch(() => 'Unknown error');
const error: UpstreamError = new Error(`Upstream error: ${response.status} ${response.statusText}`);
const error: UpstreamError = new Error(
`Upstream error: ${response.status} ${response.statusText}`
);
error.statusCode = response.status;
error.upstreamStatus = response.status;
error.retryable = isRetryableStatus(response.status);
Expand Down Expand Up @@ -484,7 +529,9 @@ export class Forwarder {
cleanup();

if (err instanceof Error && err.name === 'AbortError') {
const error: UpstreamError = new Error('Request aborted (timeout or client disconnect)');
const error: UpstreamError = new Error(
'Request aborted (timeout or client disconnect)'
);
error.statusCode = 408;
error.retryable = false;
this.handleError(error, req, res);
Expand All @@ -494,7 +541,10 @@ export class Forwarder {
lastError = err as UpstreamError;

// Check if we should retry
if (attempt < this.config.maxRetries && (lastError.retryable ?? isRetryableStatus(lastError.upstreamStatus ?? 0))) {
if (
attempt < this.config.maxRetries &&
(lastError.retryable ?? isRetryableStatus(lastError.upstreamStatus ?? 0))
) {
const delay = calculateRetryDelay(attempt, this.config.retryDelayMs);
console.warn(
`[Forwarder] Request failed, retrying in ${delay}ms (attempt ${attempt + 1}/${this.config.maxRetries}):`,
Expand Down Expand Up @@ -725,4 +775,4 @@ export function createForwarder(config: ForwarderConfig) {

// ============================================================================
// Export types and schemas for package API
// ============================================================================
// ============================================================================
Loading