/** * AgentGuard(TM) Spend: OpenRouter catalog sync. * * OpenRouter requests go directly from the customer runtime to openrouter.ai. * AgentGuard infrastructure does not receive prompts, completions, keys, * policy files, or pricing overrides. * * Patent notice: Protected by U.S. patent-pending technology * (App. Nos. 63/983,615; 63/983,621; 63/983,843; 63/984,626; * 64/071,781; 64/071,789). */ import * as fs from 'fs'; import * as https from 'https'; import * as os from 'os'; import * as path from 'path'; import { DEFAULT_COST_OVERRIDES_PATH, persistCostOverrides, setCostOverride, type ModelCost } from './cost-table'; export interface OpenRouterPricing { prompt?: string; completion?: string; image?: string; request?: string; } export interface OpenRouterModel { id: string; name?: string; context_length?: number; pricing?: OpenRouterPricing; architecture?: { modality?: string; tokenizer?: string; instruct_type?: string | null }; top_provider?: { context_length?: number; max_completion_tokens?: number | null; is_moderated?: boolean }; per_request_limits?: Record | null; [key: string]: unknown; } export interface OpenRouterCatalog { data: OpenRouterModel[]; fetchedAt?: string; } export interface FetchCatalogOptions { ttlMs?: number; force?: boolean; endpoint?: string; cachePath?: string; } export interface SyncPricingResult { count: number; applied: string[]; } const DAY_MS = 24 * 60 * 60 * 1000; const STATE_DIR = process.env.AGENTGUARD_HOME || path.join(os.homedir(), '.agentguard'); const DEFAULT_CACHE_PATH = path.join(STATE_DIR, 'openrouter-catalog.json'); const DEFAULT_ENDPOINT = 'https://openrouter.ai/api/v1/models'; let memoryCatalog: OpenRouterCatalog | null = null; export async function fetchCatalog(opts: FetchCatalogOptions = {}): Promise { const ttlMs = opts.ttlMs ?? DAY_MS; const cachePath = opts.cachePath ?? DEFAULT_CACHE_PATH; if (!opts.force) { const cached = readCachedCatalog(cachePath, ttlMs); if (cached) return cached; } const fixturePath = process.env.AGENTGUARD_OPENROUTER_CATALOG_FIXTURE; if (fixturePath) { const fixture = parseCatalog(fs.readFileSync(fixturePath, 'utf8')); return saveCatalog(fixture, cachePath); } try { const body = await getJson(opts.endpoint ?? DEFAULT_ENDPOINT); const catalog = parseCatalog(body); return saveCatalog(catalog, cachePath); } catch (err) { const cached = readCachedCatalog(cachePath, Number.MAX_SAFE_INTEGER); if (cached) return cached; throw err; } } export function getCachedCatalog(cachePath: string = DEFAULT_CACHE_PATH): OpenRouterCatalog | null { if (memoryCatalog) return memoryCatalog; return readCachedCatalog(cachePath, Number.MAX_SAFE_INTEGER); } export async function syncPricingIntoCostTable(opts: FetchCatalogOptions = {}): Promise { const catalog = await fetchCatalog(opts); const applied: string[] = []; for (const model of catalog.data) { const cost = modelCostFromOpenRouter(model); if (!cost) continue; setCostOverride(model.id, cost); applied.push(model.id); } persistCostOverrides(DEFAULT_COST_OVERRIDES_PATH); return { count: applied.length, applied }; } export function persistOverrides(filePath?: string): void { persistCostOverrides(filePath); } export function modelCostFromOpenRouter(model: OpenRouterModel): ModelCost | null { const prompt = Number(model.pricing?.prompt); const completion = Number(model.pricing?.completion); if (!Number.isFinite(prompt) || !Number.isFinite(completion) || prompt < 0 || completion < 0) { return null; } return { inputCentsPerKtok: prompt * 100 * 1000, outputCentsPerKtok: completion * 100 * 1000, }; } function readCachedCatalog(filePath: string, ttlMs: number): OpenRouterCatalog | null { try { const stat = fs.statSync(filePath); if (Date.now() - stat.mtimeMs > ttlMs) return null; const catalog = parseCatalog(fs.readFileSync(filePath, 'utf8')); memoryCatalog = catalog; return catalog; } catch { return null; } } function saveCatalog(catalog: OpenRouterCatalog, filePath: string): OpenRouterCatalog { const stamped: OpenRouterCatalog = { ...catalog, fetchedAt: catalog.fetchedAt ?? new Date().toISOString() }; memoryCatalog = stamped; try { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(stamped, null, 2) + '\n', { mode: 0o600 }); try { fs.chmodSync(filePath, 0o600); } catch { return stamped; } } catch { return stamped; } return stamped; } function parseCatalog(body: string): OpenRouterCatalog { const parsed = JSON.parse(body) as OpenRouterCatalog; if (!parsed || !Array.isArray(parsed.data)) throw new Error('OpenRouter catalog response must include data[]'); const data = parsed.data.filter((item): item is OpenRouterModel => Boolean(item && typeof item.id === 'string')); return { ...parsed, data }; } function getJson(endpoint: string): Promise { return new Promise((resolve, reject) => { const url = new URL(endpoint); const req = https.request( { method: 'GET', hostname: url.hostname, path: url.pathname + url.search, headers: { accept: 'application/json' }, timeout: 5000, }, (res) => { const chunks: Buffer[] = []; res.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); res.on('end', () => { const body = Buffer.concat(chunks).toString('utf8'); if (!res.statusCode || res.statusCode < 200 || res.statusCode >= 300) { reject(new Error('OpenRouter catalog fetch failed with HTTP ' + String(res.statusCode))); return; } resolve(body); }); }, ); req.on('error', reject); req.on('timeout', () => req.destroy(new Error('OpenRouter catalog fetch timed out'))); req.end(); }); }