Skip to content
Open
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
3 changes: 3 additions & 0 deletions packages/openrouter-execution/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import baseConfig from '@aprovan/eslint-config/base';

export default [...baseConfig];
34 changes: 34 additions & 0 deletions packages/openrouter-execution/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"name": "@aprovan/openrouter-execution",
"version": "0.0.1",
"description": "OpenRouter execution layer with BYOK and tier-based routing",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"@aprovan/eslint-config": "workspace:*",
"@aprovan/tsconfig": "workspace:*",
"@aprovan/vitest-config": "workspace:*",
"@types/node": "^22.0.0",
"tsup": "^8.3.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
138 changes: 138 additions & 0 deletions packages/openrouter-execution/src/__tests__/executor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { execute } from '../executor.js';
import { ModelTier, ExecutionError } from '../types.js';

const TEST_CONFIG = {
openRouterApiKey: 'test-key',
appName: 'AprovanLabs Test',
maxBudgetUsd: 0.1,
maxRetries: 1,
};

function makeSuccessResponse(model = 'deepseek/deepseek-v4-flash:free', content = 'Hello!'): object {
return {
id: 'test-id',
model,
choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, cost: 0.001 },
};
}

describe('execute', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});

afterEach(() => {
vi.restoreAllMocks();
});

it('routes complexity=1 to a free model', async () => {
fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(makeSuccessResponse()), { status: 200 }));

const result = await execute(
{ prompt: 'Hello', complexity: 1, costQualityTradeoff: 0 },
TEST_CONFIG,
);

expect(result.tier).toBe(ModelTier.FREE);
expect(result.fallbackUsed).toBe(false);
expect(result.text).toBe('Hello!');

const body = JSON.parse((fetchSpy.mock.calls[0]![1] as RequestInit).body as string);
expect(body.model).toContain(':free');
});

it('routes complexity=4, tradeoff=8 to frontier tier', async () => {
const model = 'anthropic/claude-opus-4-6';
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify(makeSuccessResponse(model, 'Deep answer')), { status: 200 }),
);

const result = await execute(
{ prompt: 'Hard task', complexity: 4, costQualityTradeoff: 8 },
TEST_CONFIG,
);

expect(result.tier).toBe(ModelTier.FRONTIER);
const body = JSON.parse((fetchSpy.mock.calls[0]![1] as RequestInit).body as string);
expect(body.model).toBe(model);
});

it('falls back to second model when first returns 500', async () => {
fetchSpy
.mockResolvedValueOnce(new Response('Server Error', { status: 500, statusText: 'Internal Server Error' }))
.mockResolvedValueOnce(new Response('Server Error', { status: 500, statusText: 'Internal Server Error' }))
// First model exhausted (maxRetries=1, so 2 attempts) → fallback to second model
.mockResolvedValueOnce(new Response(JSON.stringify(makeSuccessResponse('anthropic/claude-haiku-4-5', 'Fallback reply')), { status: 200 }));

const result = await execute(
{ prompt: 'Test', complexity: 3, costQualityTradeoff: 0 },
TEST_CONFIG,
);

expect(result.fallbackUsed).toBe(true);
expect(result.text).toBe('Fallback reply');
expect(fetchSpy).toHaveBeenCalledTimes(3);
});

it('includes provider preferences in request body', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify(makeSuccessResponse('anthropic/claude-sonnet-4-6')), { status: 200 }),
);

await execute({ prompt: 'Mid task', complexity: 3, costQualityTradeoff: 7 }, TEST_CONFIG);

const body = JSON.parse((fetchSpy.mock.calls[0]![1] as RequestInit).body as string);
expect(body.provider).toBeDefined();
expect(body.provider.allow_fallbacks).toBe(true);
expect(Array.isArray(body.provider.order)).toBe(true);
});

it('includes max_price when budget is configured', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify(makeSuccessResponse()), { status: 200 }),
);

await execute({ prompt: 'Budget test', complexity: 1, costQualityTradeoff: 0 }, TEST_CONFIG);

const body = JSON.parse((fetchSpy.mock.calls[0]![1] as RequestInit).body as string);
expect(body.max_price).toEqual({ total: 0.1 });
});

it('throws ExecutionError with CONFIG_ERROR when API key is missing', async () => {
await expect(
execute({ prompt: 'Test', complexity: 1, costQualityTradeoff: 0 }),
).rejects.toMatchObject({ code: 'CONFIG_ERROR' });
});

it('returns usage stats from response', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify(makeSuccessResponse()), { status: 200 }),
);

const result = await execute(
{ prompt: 'Stats test', complexity: 1, costQualityTradeoff: 0 },
TEST_CONFIG,
);

expect(result.usage).toMatchObject({
promptTokens: 10,
completionTokens: 5,
totalTokens: 15,
estimatedCost: 0.001,
});
});

it('throws ExecutionError when all models fail', async () => {
fetchSpy.mockResolvedValue(
new Response('Bad Gateway', { status: 502, statusText: 'Bad Gateway' }),
);

await expect(
execute({ prompt: 'Doom', complexity: 1, costQualityTradeoff: 0 }, TEST_CONFIG),
).rejects.toBeInstanceOf(ExecutionError);
});
});
59 changes: 59 additions & 0 deletions packages/openrouter-execution/src/__tests__/tier-router.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest';
import { resolveTier } from '../tier-router.js';
import { ModelTier } from '../types.js';

describe('resolveTier', () => {
describe('free tier (complexity ≤ 2, tradeoff < 7)', () => {
it('complexity=1, tradeoff=0 → FREE', () => {
expect(resolveTier(1, 0)).toBe(ModelTier.FREE);
});
it('complexity=2, tradeoff=6 → FREE', () => {
expect(resolveTier(2, 6)).toBe(ModelTier.FREE);
});
});

describe('quality upgrade from free tier (tradeoff ≥ 7)', () => {
it('complexity=1, tradeoff=7 → BUDGET', () => {
expect(resolveTier(1, 7)).toBe(ModelTier.BUDGET);
});
it('complexity=2, tradeoff=10 → BUDGET', () => {
expect(resolveTier(2, 10)).toBe(ModelTier.BUDGET);
});
});

describe('medium complexity', () => {
it('complexity=3, tradeoff=0 → BUDGET', () => {
expect(resolveTier(3, 0)).toBe(ModelTier.BUDGET);
});
it('complexity=3, tradeoff=6 → BUDGET', () => {
expect(resolveTier(3, 6)).toBe(ModelTier.BUDGET);
});
it('complexity=3, tradeoff=7 → MID_TIER', () => {
expect(resolveTier(3, 7)).toBe(ModelTier.MID_TIER);
});
it('complexity=3, tradeoff=10 → MID_TIER', () => {
expect(resolveTier(3, 10)).toBe(ModelTier.MID_TIER);
});
});

describe('high complexity', () => {
it('complexity=4, tradeoff=0 → MID_TIER', () => {
expect(resolveTier(4, 0)).toBe(ModelTier.MID_TIER);
});
it('complexity=4, tradeoff=6 → MID_TIER', () => {
expect(resolveTier(4, 6)).toBe(ModelTier.MID_TIER);
});
it('complexity=4, tradeoff=7 → FRONTIER', () => {
expect(resolveTier(4, 7)).toBe(ModelTier.FRONTIER);
});
});

describe('maximum complexity', () => {
it('complexity=5, tradeoff=0 → FRONTIER', () => {
expect(resolveTier(5, 0)).toBe(ModelTier.FRONTIER);
});
it('complexity=5, tradeoff=10 → FRONTIER', () => {
expect(resolveTier(5, 10)).toBe(ModelTier.FRONTIER);
});
});
});
151 changes: 151 additions & 0 deletions packages/openrouter-execution/src/executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { MODEL_CATALOG } from './model-catalog.js';
import { OpenRouterClient, type ChatMessage } from './openrouter-client.js';
import { resolveTier } from './tier-router.js';
import { ExecutionError, type ExecutionRequest, type ExecutionResult } from './types.js';

export interface ExecutorConfig {
openRouterApiKey: string;
appName?: string;
appURL?: string;
timeoutMs?: number;
/**
* Per-request credit budget cap in USD.
* Requests that would exceed this total price are rejected by OpenRouter.
* Default: $0.50.
*/
maxBudgetUsd?: number;
/**
* Max retry attempts per model on retryable errors (rate-limit, timeout, 5xx).
* Default: 2.
*/
maxRetries?: number;
}

function loadConfig(): ExecutorConfig {
const apiKey = process.env['OPENROUTER_API_KEY'];
if (!apiKey) {
throw new ExecutionError(
'OPENROUTER_API_KEY environment variable is required',
'CONFIG_ERROR',
false,
false,
);
}
const budgetStr = process.env['OPENROUTER_MAX_BUDGET_USD'];
return {
openRouterApiKey: apiKey,
appName: process.env['OPENROUTER_APP_NAME'] ?? 'AprovanLabs',
appURL: process.env['OPENROUTER_APP_URL'] ?? 'https://aprovan.com',
maxBudgetUsd: budgetStr ? parseFloat(budgetStr) : 0.5,
maxRetries: 2,
};
}

function buildMessages(req: ExecutionRequest): ChatMessage[] {
const messages: ChatMessage[] = [];
if (req.systemPrompt) {
messages.push({ role: 'system', content: req.systemPrompt });
}
messages.push({ role: 'user', content: req.prompt });
return messages;
}

/**
* Executes a prompt against the best available model for the given complexity
* and cost-quality tradeoff, routing through OpenRouter with BYOK-first
* provider preferences and automatic fallback across models in the tier.
*
* Routing priority within each tier:
* 1. First model in catalog (BYOK subscription key used automatically by OpenRouter)
* 2. Subsequent models as fallback if first fails
*
* For complexity ≤ 2, routes to free models first unless tradeoff ≥ 7.
*/
export async function execute(
request: ExecutionRequest,
config?: ExecutorConfig,
): Promise<ExecutionResult> {
const cfg = config ?? loadConfig();
const client = new OpenRouterClient({
apiKey: cfg.openRouterApiKey,
appName: cfg.appName,
appURL: cfg.appURL,
timeoutMs: cfg.timeoutMs,
});

const tier = resolveTier(request.complexity, request.costQualityTradeoff);
const models = MODEL_CATALOG[tier];
const messages = buildMessages(request);
const maxRetries = cfg.maxRetries ?? 2;

let lastError: ExecutionError | undefined;
let fallbackUsed = false;

for (let modelIdx = 0; modelIdx < models.length; modelIdx++) {
const model = models[modelIdx]!;

for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await client.complete({
model: model.id,
messages,
temperature: request.temperature ?? 0.2,
max_tokens: request.maxTokens,
provider: {
order: model.providers,
allow_fallbacks: true,
},
...(cfg.maxBudgetUsd != null && {
max_price: { total: cfg.maxBudgetUsd },
}),
});

const choice = response.choices[0];
if (!choice) {
throw new ExecutionError('Empty response from model', 'EMPTY_RESPONSE', true, true);
}

return {
text: choice.message.content,
model: response.model,
provider: model.providers[0] ?? 'openrouter',
tier,
usage: response.usage
? {
promptTokens: response.usage.prompt_tokens,
completionTokens: response.usage.completion_tokens,
totalTokens: response.usage.total_tokens,
estimatedCost: response.usage.cost,
}
: undefined,
finishReason: choice.finish_reason,
fallbackUsed,
fallbackReason: fallbackUsed
? `Primary model unavailable, using ${model.name}`
: undefined,
};
} catch (err) {
const execErr =
err instanceof ExecutionError
? err
: new ExecutionError(
String(err instanceof Error ? err.message : err),
'UNEXPECTED',
false,
false,
err instanceof Error ? err : undefined,
);

lastError = execErr;

if (!execErr.retryable || attempt >= maxRetries) break;
}
}

// All retries for this model exhausted — try next as fallback
fallbackUsed = true;
}

throw lastError ??
new ExecutionError('All models in tier exhausted', 'ALL_FAILED', false, false);
}
Loading