/** * HTTP client for the allaigate / cortiq-router. * * Calls `POST /v1/route` with the prompt text, returns the router's decision: * `task_label`, `complexity.{score, tier}`, `confidence`, plus metadata. * * The contract matches the allaigate classifier (https://router.allaigate.com). * See also cortiq-gateway/src/router_client.rs for the reference Rust client. */ // ── Response shapes from the router API ────────────────────────────────── export interface ComplexityOut { score: number; tier: string; factors?: { base: number; difficulty?: number | null; ambiguity: number; novelty: number; margin: number; length: number; }; } export interface DecisionOut { task_id: number; task_label: string; taxonomy_id: string; confidence: number; confident: boolean; raw_confidence: number; margin: number; is_novel: boolean; novelty_score: number; complexity: ComplexityOut; source: string; // "router" | "cache" | "oracle" flags: string[]; } export interface ScoreOut { task_id: number; task_label: string; probability: number; score: number; reconstruction_error: number; } export interface RouteResponse { schema_version: string; request_id: string; client_request_id?: string | null; decision: DecisionOut; scores: ScoreOut[]; routing?: { target: string; reason: string } | null; oracle?: { consulted: boolean; model?: string | null; agreement_with_router?: boolean | null; latency_ms?: number | null; } | null; explanation?: { top1_vs_top2: string; decision_path: string } | null; usage: { billable_decisions: number; oracle_calls: number }; meta: { model_version: string; taxonomy_version: string; latency_ms: number; embedding_latency_ms: number; served_by: string; }; } /** The subset of RouteResponse that matters for model routing. */ export interface RouteDecision { taskLabel: string; complexityScore: number; complexityTier: string; confidence: number; requestId: string; isNovel: boolean; source: string; } // ── Error types ────────────────────────────────────────────────────────── export enum RouterErrorKind { Network = 'network', Timeout = 'timeout', Auth = 'auth', Payment = 'payment', Quota = 'quota', Server = 'server', InvalidRequest = 'invalid_request', } export class RouterError extends Error { constructor( public kind: RouterErrorKind, message: string, public httpStatus?: number, ) { super(message); this.name = 'RouterError'; } } // ── Client ─────────────────────────────────────────────────────────────── export interface RouterClientOptions { /** Classifier base URL, e.g. "https://router.allaigate.com" or your own deployment. */ url: string; /** API key (cortiq_...) */ apiKey: string; /** Whether to skip TLS verification (needed for the IP-bound hosted router) */ verifyTls?: boolean; /** Request timeout in ms (the hosted router escalates hard prompts to oracle ~10s) */ timeoutMs?: number; /** Taxonomy id, e.g. "data-assistant" */ taxonomyId?: string; } export class RouterApiClient { private baseUrl: string; private apiKey: string; private taxonomyId?: string; private timeoutMs: number; /** Controllers for the calls currently in flight, so `abort()` reaches all of them. */ private inFlight = new Set(); constructor(opts: RouterClientOptions) { this.baseUrl = opts.url.replace(/\/+$/, ''); this.apiKey = opts.apiKey; this.taxonomyId = opts.taxonomyId; this.timeoutMs = opts.timeoutMs ?? 15000; } /** Abort every in-flight request (for cleanup). */ abort(): void { for (const controller of this.inFlight) controller.abort(); } /** * Classify text → route decision. * Returns null on network/timeout error (graceful degradation). * Throws RouterError on auth/payment/quota issues. * @param text - the prompt text to classify. * @param profile - policy profile for this call. * @param signal - the caller's cancellation, honored alongside the timeout. */ async route(text: string, profile?: string, signal?: AbortSignal): Promise { if (!this.apiKey) return null; const controller = new AbortController(); this.inFlight.add(controller); const abortFromCaller = () => controller.abort(); signal?.addEventListener('abort', abortFromCaller, { once: true }); if (signal?.aborted === true) controller.abort(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { const body: Record = { input: { text }, options: { policy_profile: profile ?? 'balanced', allow_oracle: true, return_explanation: false, top_k: 3, }, }; if (this.taxonomyId) { body['taxonomy_id'] = this.taxonomyId; } const response = await fetch(`${this.baseUrl}/v1/route`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${this.apiKey}`, }, body: JSON.stringify(body), signal: controller.signal, }); if (!response.ok) { switch (response.status) { case 401: case 403: throw new RouterError(RouterErrorKind.Auth, `Router auth failed (HTTP ${response.status})`, response.status); case 402: throw new RouterError(RouterErrorKind.Payment, 'Router: payment required / quota exceeded', response.status); case 429: throw new RouterError(RouterErrorKind.Quota, 'Router: rate limited', response.status); default: throw new RouterError(RouterErrorKind.Server, `Router HTTP ${response.status}`, response.status); } } const data: RouteResponse = await response.json(); const d = data.decision; return { taskLabel: d.task_label, complexityScore: d.complexity.score, complexityTier: d.complexity.tier, confidence: d.confidence, requestId: data.request_id, isNovel: d.is_novel, source: d.source, }; } catch (err: unknown) { if (err instanceof RouterError) throw err; // The caller cancelled the whole call: that is not a routing failure to // degrade past, it is the answer. if (signal?.aborted === true) throw err; // Timeout, manual abort, or network error → degrade to the fallback chain. return null; } finally { clearTimeout(timeout); signal?.removeEventListener('abort', abortFromCaller); this.inFlight.delete(controller); } } /** * Fetch available taxonomies (model labels) from the router. * Useful to discover what task labels the account has. */ async fetchTaxonomies(): Promise<{ labels: string[] } | null> { if (!this.apiKey) return null; try { const response = await fetch(`${this.baseUrl}/v1/taxonomies`, { headers: { Authorization: `Bearer ${this.apiKey}`, }, }); if (!response.ok) return null; const data = await response.json(); // The shape: { schema_version, taxonomies: [{ taxonomy_id, labels: [...] }] } const taxonomies = data.taxonomies; if (Array.isArray(taxonomies) && taxonomies.length > 0) { return { labels: taxonomies[0].labels ?? [] }; } return null; } catch { return null; } } }