From f3e3799e94bb719e9a79eaae7bdd0039fa0ccbc4 Mon Sep 17 00:00:00 2001 From: JacobSampson Date: Fri, 12 Jun 2026 19:18:26 -0500 Subject: [PATCH 1/2] feat(openrouter-execution): add OpenRouter execution layer with BYOK and tier-based routing (APR-206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements @aprovan/openrouter-execution — the execution layer that sits between the model selection engine (APR-205) and the OpenRouter API. - Tier router maps (complexity 1-5, cost_quality_tradeoff 0-10) to FREE / BUDGET / MID_TIER / FRONTIER model tiers - Model catalog defines ordered fallback lists per tier using OpenRouter model IDs; BYOK subscription keys registered in OpenRouter are used automatically - OpenRouterClient wraps the /chat/completions endpoint with provider preferences, per-request budget caps (max_price), and retryable error handling - execute() tries each model in the tier in order, retrying on 429/5xx before falling back to the next model; reports fallback usage in the result - 21 unit tests covering tier routing and executor behavior (fetch is mocked) Co-Authored-By: Claude Sonnet 4.6 --- packages/openrouter-execution/package.json | 34 ++++ .../src/__tests__/executor.test.ts | 138 ++++++++++++++++ .../src/__tests__/tier-router.test.ts | 59 +++++++ packages/openrouter-execution/src/executor.ts | 151 ++++++++++++++++++ packages/openrouter-execution/src/index.ts | 32 ++++ .../openrouter-execution/src/model-catalog.ts | 37 +++++ .../src/openrouter-client.ts | 116 ++++++++++++++ .../openrouter-execution/src/tier-router.ts | 28 ++++ packages/openrouter-execution/src/types.ts | 123 ++++++++++++++ packages/openrouter-execution/tsconfig.json | 12 ++ packages/openrouter-execution/tsup.config.ts | 11 ++ .../openrouter-execution/vitest.config.ts | 10 ++ 12 files changed, 751 insertions(+) create mode 100644 packages/openrouter-execution/package.json create mode 100644 packages/openrouter-execution/src/__tests__/executor.test.ts create mode 100644 packages/openrouter-execution/src/__tests__/tier-router.test.ts create mode 100644 packages/openrouter-execution/src/executor.ts create mode 100644 packages/openrouter-execution/src/index.ts create mode 100644 packages/openrouter-execution/src/model-catalog.ts create mode 100644 packages/openrouter-execution/src/openrouter-client.ts create mode 100644 packages/openrouter-execution/src/tier-router.ts create mode 100644 packages/openrouter-execution/src/types.ts create mode 100644 packages/openrouter-execution/tsconfig.json create mode 100644 packages/openrouter-execution/tsup.config.ts create mode 100644 packages/openrouter-execution/vitest.config.ts diff --git a/packages/openrouter-execution/package.json b/packages/openrouter-execution/package.json new file mode 100644 index 0000000..6e160cf --- /dev/null +++ b/packages/openrouter-execution/package.json @@ -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 --ext 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" + } +} \ No newline at end of file diff --git a/packages/openrouter-execution/src/__tests__/executor.test.ts b/packages/openrouter-execution/src/__tests__/executor.test.ts new file mode 100644 index 0000000..17eb37a --- /dev/null +++ b/packages/openrouter-execution/src/__tests__/executor.test.ts @@ -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!') { + 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; + + 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); + }); +}); diff --git a/packages/openrouter-execution/src/__tests__/tier-router.test.ts b/packages/openrouter-execution/src/__tests__/tier-router.test.ts new file mode 100644 index 0000000..f8e0106 --- /dev/null +++ b/packages/openrouter-execution/src/__tests__/tier-router.test.ts @@ -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); + }); + }); +}); diff --git a/packages/openrouter-execution/src/executor.ts b/packages/openrouter-execution/src/executor.ts new file mode 100644 index 0000000..158f1b4 --- /dev/null +++ b/packages/openrouter-execution/src/executor.ts @@ -0,0 +1,151 @@ +import { ExecutionError, type ExecutionRequest, type ExecutionResult } from './types.js'; +import { resolveTier } from './tier-router.js'; +import { MODEL_CATALOG } from './model-catalog.js'; +import { OpenRouterClient, type ChatMessage } from './openrouter-client.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 { + 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); +} diff --git a/packages/openrouter-execution/src/index.ts b/packages/openrouter-execution/src/index.ts new file mode 100644 index 0000000..51b6146 --- /dev/null +++ b/packages/openrouter-execution/src/index.ts @@ -0,0 +1,32 @@ +export { + execute, + type ExecutorConfig, +} from './executor.js'; + +export { + ComplexityScoreSchema, + CostQualityTradeoffSchema, + ModelTier, + ExecutionError, + ExecutionRequestSchema, + ExecutionResultSchema, + TierRoutingConfigSchema, + type ComplexityScore, + type CostQualityTradeoff, + type ExecutionRequest, + type ExecutionResult, + type ModelConfig, + type ProviderConfig, + type TierRoutingConfig, +} from './types.js'; + +export { resolveTier } from './tier-router.js'; + +export { MODEL_CATALOG, type CatalogEntry } from './model-catalog.js'; + +export { + OpenRouterClient, + type OpenRouterClientConfig, + type CompletionRequest, + type CompletionResponse, +} from './openrouter-client.js'; diff --git a/packages/openrouter-execution/src/model-catalog.ts b/packages/openrouter-execution/src/model-catalog.ts new file mode 100644 index 0000000..87ed092 --- /dev/null +++ b/packages/openrouter-execution/src/model-catalog.ts @@ -0,0 +1,37 @@ +import { ModelTier } from './types.js'; + +export interface CatalogEntry { + id: string; + name: string; + tier: ModelTier; + /** Preferred OpenRouter provider names for routing (e.g. ['Anthropic', 'OpenAI']) */ + providers: string[]; +} + +/** + * Models ordered by preference within each tier. + * Subscription BYOK keys registered in OpenRouter will be used automatically + * when the provider matches — no extra per-request config needed. + */ +export const MODEL_CATALOG: Record = { + [ModelTier.FREE]: [ + { id: 'deepseek/deepseek-v4-flash:free', name: 'DeepSeek V4 Flash (Free)', tier: ModelTier.FREE, providers: ['DeepSeek'] }, + { id: 'meta-llama/llama-4-scout:free', name: 'Llama 4 Scout (Free)', tier: ModelTier.FREE, providers: ['Meta'] }, + { id: 'qwen/qwen3-coder:free', name: 'Qwen3 Coder (Free)', tier: ModelTier.FREE, providers: ['Qwen'] }, + ], + [ModelTier.BUDGET]: [ + { id: 'deepseek/deepseek-v3.2', name: 'DeepSeek V3.2', tier: ModelTier.BUDGET, providers: ['DeepSeek'] }, + { id: 'anthropic/claude-haiku-4-5', name: 'Claude Haiku 4.5', tier: ModelTier.BUDGET, providers: ['Anthropic'] }, + { id: 'openai/gpt-4.1-mini', name: 'GPT-4.1 mini', tier: ModelTier.BUDGET, providers: ['OpenAI'] }, + ], + [ModelTier.MID_TIER]: [ + { id: 'anthropic/claude-sonnet-4-6', name: 'Claude Sonnet 4.6', tier: ModelTier.MID_TIER, providers: ['Anthropic'] }, + { id: 'openai/gpt-5.2', name: 'GPT-5.2', tier: ModelTier.MID_TIER, providers: ['OpenAI'] }, + { id: 'google/gemini-2.5-flash', name: 'Gemini 2.5 Flash', tier: ModelTier.MID_TIER, providers: ['Google'] }, + ], + [ModelTier.FRONTIER]: [ + { id: 'anthropic/claude-opus-4-6', name: 'Claude Opus 4.6', tier: ModelTier.FRONTIER, providers: ['Anthropic'] }, + { id: 'anthropic/claude-sonnet-4-6:thinking', name: 'Claude Sonnet 4.6 (Thinking)', tier: ModelTier.FRONTIER, providers: ['Anthropic'] }, + { id: 'openai/gpt-5.2', name: 'GPT-5.2', tier: ModelTier.FRONTIER, providers: ['OpenAI'] }, + ], +}; diff --git a/packages/openrouter-execution/src/openrouter-client.ts b/packages/openrouter-execution/src/openrouter-client.ts new file mode 100644 index 0000000..06f0df0 --- /dev/null +++ b/packages/openrouter-execution/src/openrouter-client.ts @@ -0,0 +1,116 @@ +import { ExecutionError } from './types.js'; + +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface ProviderPreferences { + /** Ordered list of provider names to prefer (e.g. ['Anthropic', 'OpenAI']). */ + order?: string[]; + /** Allow OpenRouter to try other providers if preferred ones fail. Default true. */ + allow_fallbacks?: boolean; +} + +export interface CompletionRequest { + model: string; + messages: ChatMessage[]; + temperature?: number; + max_tokens?: number; + provider?: ProviderPreferences; + /** + * Per-request budget cap (USD) to prevent runaway costs. + * OpenRouter rejects requests that would exceed this total price. + */ + max_price?: { + total?: number; + prompt?: number; + completion?: number; + }; +} + +export interface CompletionResponse { + id: string; + model: string; + choices: Array<{ + message: { role: string; content: string }; + finish_reason: string; + }>; + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + /** Actual cost in USD (populated by OpenRouter). */ + cost?: number; + }; +} + +export interface OpenRouterClientConfig { + apiKey: string; + baseURL?: string; + /** Application name sent in X-Title header for OpenRouter analytics. */ + appName?: string; + /** Application URL sent in HTTP-Referer header. */ + appURL?: string; + timeoutMs?: number; +} + +export class OpenRouterClient { + private readonly baseURL: string; + private readonly timeoutMs: number; + private readonly headers: Record; + + constructor(config: OpenRouterClientConfig) { + this.baseURL = config.baseURL ?? OPENROUTER_BASE_URL; + this.timeoutMs = config.timeoutMs ?? 60_000; + this.headers = { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + ...(config.appName ? { 'X-Title': config.appName } : {}), + ...(config.appURL ? { 'HTTP-Referer': config.appURL } : {}), + }; + } + + async complete(req: CompletionRequest): Promise { + const controller = new AbortController(); + const timerId = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const res = await fetch(`${this.baseURL}/chat/completions`, { + method: 'POST', + headers: this.headers, + body: JSON.stringify(req), + signal: controller.signal, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + const retryable = res.status === 429 || res.status >= 500; + throw new ExecutionError( + `OpenRouter API error ${res.status} ${res.statusText}: ${body}`, + res.status === 429 ? 'RATE_LIMIT' : 'API_ERROR', + retryable, + retryable, + ); + } + + return (await res.json()) as CompletionResponse; + } catch (err) { + if (err instanceof ExecutionError) throw err; + if (err instanceof Error && err.name === 'AbortError') { + throw new ExecutionError('Request timed out', 'TIMEOUT', true, true, err); + } + throw new ExecutionError( + `Network error: ${err instanceof Error ? err.message : String(err)}`, + 'NETWORK_ERROR', + true, + true, + err instanceof Error ? err : undefined, + ); + } finally { + clearTimeout(timerId); + } + } +} diff --git a/packages/openrouter-execution/src/tier-router.ts b/packages/openrouter-execution/src/tier-router.ts new file mode 100644 index 0000000..9bc877e --- /dev/null +++ b/packages/openrouter-execution/src/tier-router.ts @@ -0,0 +1,28 @@ +import { ModelTier, type ComplexityScore, type CostQualityTradeoff } from './types.js'; + +/** + * Maps a complexity score and cost-quality tradeoff to a model tier. + * + * Complexity defines the base tier; tradeoff nudges into adjacent tiers: + * 0-3 = cost-priority (stay cheap or go cheaper) + * 4-6 = balanced (follow base complexity) + * 7-10 = quality-priority (upgrade one tier when possible) + * + * Free models are always tried first for complexity ≤ 2 unless tradeoff ≥ 7. + */ +export function resolveTier( + complexity: ComplexityScore, + tradeoff: CostQualityTradeoff, +): ModelTier { + if (complexity <= 2) { + return tradeoff >= 7 ? ModelTier.BUDGET : ModelTier.FREE; + } + if (complexity === 3) { + return tradeoff >= 7 ? ModelTier.MID_TIER : ModelTier.BUDGET; + } + if (complexity === 4) { + return tradeoff >= 7 ? ModelTier.FRONTIER : ModelTier.MID_TIER; + } + // complexity === 5: always frontier regardless of tradeoff + return ModelTier.FRONTIER; +} diff --git a/packages/openrouter-execution/src/types.ts b/packages/openrouter-execution/src/types.ts new file mode 100644 index 0000000..973f07f --- /dev/null +++ b/packages/openrouter-execution/src/types.ts @@ -0,0 +1,123 @@ +import { z } from 'zod'; + +/** + * Complexity score from model selection engine (1-5) + * 1 = very simple, 5 = extremely complex + */ +export const ComplexityScoreSchema = z.number().int().min(1).max(5); +export type ComplexityScore = z.infer; + +/** + * Cost-quality tradeoff parameter (0-10) from model selection engine + * 0 = prioritize cost (cheapest), 10 = prioritize quality (best model) + */ +export const CostQualityTradeoffSchema = z.number().int().min(0).max(10); +export type CostQualityTradeoff = z.infer; + +/** + * Model tier based on complexity and cost-quality tradeoff + */ +export enum ModelTier { + FREE = 'free', // OpenRouter free models (complexity 1-2) + BUDGET = 'budget', // Budget paid models (complexity 2-3) + MID_TIER = 'mid_tier', // Mid-tier models (complexity 3-4) + FRONTIER = 'frontier', // Frontier models (complexity 5) +} + +/** + * Provider configuration for BYOK and OpenRouter + */ +export const ProviderConfigSchema = z.object({ + name: z.string(), + type: z.enum(['byok', 'openrouter', 'direct']), + apiKey: z.string().optional(), + baseURL: z.string().optional(), + models: z.array(z.string()), + priority: z.number().int().min(0).default(0), + creditBudget: z.number().optional(), // Max credits to spend (for OpenRouter) + rateLimit: z.object({ + requestsPerMinute: z.number().optional(), + tokensPerMinute: z.number().optional(), + }).optional(), +}); +export type ProviderConfig = z.infer; + +/** + * Model configuration within a tier + */ +export const ModelConfigSchema = z.object({ + id: z.string(), + provider: z.string(), + tier: z.nativeEnum(ModelTier), + costPer1kTokens: z.number().optional(), // Input cost + costPer1kOutputTokens: z.number().optional(), // Output cost + qualityScore: z.number().min(0).max(10).optional(), + supportsFreeTier: z.boolean().default(false), + maxTokens: z.number().optional(), + capabilities: z.array(z.string()).optional(), +}); +export type ModelConfig = z.infer; + +/** + * Execution request from model selection engine + */ +export const ExecutionRequestSchema = z.object({ + prompt: z.string(), + systemPrompt: z.string().optional(), + complexity: ComplexityScoreSchema, + costQualityTradeoff: CostQualityTradeoffSchema, + maxTokens: z.number().optional(), + temperature: z.number().min(0).max(2).optional(), + taskType: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); +export type ExecutionRequest = z.infer; + +/** + * Execution result + */ +export const ExecutionResultSchema = z.object({ + text: z.string(), + model: z.string(), + provider: z.string(), + tier: z.nativeEnum(ModelTier), + usage: z.object({ + promptTokens: z.number(), + completionTokens: z.number(), + totalTokens: z.number(), + estimatedCost: z.number().optional(), + }).optional(), + finishReason: z.string().optional(), + fallbackUsed: z.boolean().default(false), + fallbackReason: z.string().optional(), +}); +export type ExecutionResult = z.infer; + +/** + * Execution error with retry/fallback info + */ +export class ExecutionError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly retryable: boolean = false, + public readonly fallbackSuggested: boolean = false, + public readonly originalError?: Error + ) { + super(message); + this.name = 'ExecutionError'; + } +} + +/** + * Tier routing configuration + */ +export const TierRoutingConfigSchema = z.object({ + tier: z.nativeEnum(ModelTier), + complexityRange: z.tuple([ComplexityScoreSchema, ComplexityScoreSchema]), + preferredProviders: z.array(z.string()), + fallbackProviders: z.array(z.string()), + minCostQualityTradeoff: z.number().min(0).max(10).optional(), + maxCostQualityTradeoff: z.number().min(0).max(10).optional(), +}); +export type TierRoutingConfig = z.infer; \ No newline at end of file diff --git a/packages/openrouter-execution/tsconfig.json b/packages/openrouter-execution/tsconfig.json new file mode 100644 index 0000000..bea2931 --- /dev/null +++ b/packages/openrouter-execution/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@aprovan/tsconfig/node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declarationMap": true, + "sourceMap": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/openrouter-execution/tsup.config.ts b/packages/openrouter-execution/tsup.config.ts new file mode 100644 index 0000000..450e817 --- /dev/null +++ b/packages/openrouter-execution/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm'], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + external: ['zod'], +}); \ No newline at end of file diff --git a/packages/openrouter-execution/vitest.config.ts b/packages/openrouter-execution/vitest.config.ts new file mode 100644 index 0000000..0de1d48 --- /dev/null +++ b/packages/openrouter-execution/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + testTimeout: 10_000, + hookTimeout: 10_000, + include: ['src/**/*.test.ts', 'src/__tests__/**/*.test.ts'], + }, +}); From b22bbb7906ff437a9c53d10be60f504d8c0f20a6 Mon Sep 17 00:00:00 2001 From: JacobSampson Date: Fri, 12 Jun 2026 19:19:37 -0500 Subject: [PATCH 2/2] chore(openrouter-execution): fix eslint config, import order, and tsconfig - Add eslint.config.mjs (ESLint 9 flat config extending @aprovan/eslint-config/base) - Fix lint script to use ESLint 9 glob pattern - Fix import order in executor.ts (alphabetical per import/order rule) - Fix tsconfig to extend @aprovan/tsconfig/node.json (not /package.json) - Add lib: ["ES2022", "DOM"] for fetch/AbortController types - Add @types/node devDependency for process.env access - Add return type to test helper function Co-Authored-By: Claude Sonnet 4.6 --- packages/openrouter-execution/eslint.config.mjs | 3 +++ packages/openrouter-execution/package.json | 2 +- packages/openrouter-execution/src/__tests__/executor.test.ts | 2 +- packages/openrouter-execution/src/executor.ts | 4 ++-- 4 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 packages/openrouter-execution/eslint.config.mjs diff --git a/packages/openrouter-execution/eslint.config.mjs b/packages/openrouter-execution/eslint.config.mjs new file mode 100644 index 0000000..09d2fa9 --- /dev/null +++ b/packages/openrouter-execution/eslint.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from '@aprovan/eslint-config/base'; + +export default [...baseConfig]; diff --git a/packages/openrouter-execution/package.json b/packages/openrouter-execution/package.json index 6e160cf..2545394 100644 --- a/packages/openrouter-execution/package.json +++ b/packages/openrouter-execution/package.json @@ -15,7 +15,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", - "lint": "eslint src --ext ts", + "lint": "eslint \"src/**/*.ts\"", "typecheck": "tsc --noEmit", "test": "vitest run" }, diff --git a/packages/openrouter-execution/src/__tests__/executor.test.ts b/packages/openrouter-execution/src/__tests__/executor.test.ts index 17eb37a..5b6df4c 100644 --- a/packages/openrouter-execution/src/__tests__/executor.test.ts +++ b/packages/openrouter-execution/src/__tests__/executor.test.ts @@ -9,7 +9,7 @@ const TEST_CONFIG = { maxRetries: 1, }; -function makeSuccessResponse(model = 'deepseek/deepseek-v4-flash:free', content = 'Hello!') { +function makeSuccessResponse(model = 'deepseek/deepseek-v4-flash:free', content = 'Hello!'): object { return { id: 'test-id', model, diff --git a/packages/openrouter-execution/src/executor.ts b/packages/openrouter-execution/src/executor.ts index 158f1b4..3e5e664 100644 --- a/packages/openrouter-execution/src/executor.ts +++ b/packages/openrouter-execution/src/executor.ts @@ -1,7 +1,7 @@ -import { ExecutionError, type ExecutionRequest, type ExecutionResult } from './types.js'; -import { resolveTier } from './tier-router.js'; 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;