/** * @coreci/byom/router — BYOM routing shim (REQ-008, REQ-009, G-001). * * `routeInference(db, secrets, tenantId, payload)` resolves the tenant's * validated BYOM endpoint, fetches the API key from the secret manager, and * POSTs the OpenAI-compatible chat completion request to `{url}/v1/chat/completions` * (D-001). On 200 → returns the parsed ChatCompletionResponse. On * unconfigured/unreachable → throws ByomUnconfiguredError / ByomUnreachableError * (REQ-009, Edge 1) so the API gateway can return a clear actionable error and * NO inference is attempted. * * [G-001] Scope note: in M1 there is no M3 chat orchestrator to drive inference, * so the control plane exposes `POST /api/byom/test-inference` (Admin only) as a * plan-time proxy to satisfy REQ-008 ("100% of LLM inference calls routed to * BYOM, verified via outbound traffic log"). This shim is the single egress * point for that proxy and for the future M3 orchestrator. Marked for M3 * deprecation of the proxy endpoint — the shim itself persists. * * REQ-009 error semantics: * - ByomUnconfiguredError → no validated BYOM endpoint for the tenant → 400. * - ByomUnreachableError → endpoint unreachable OR rejected the key (401/403 * is treated as unreachable for the operator, since a previously-validated * endpoint returning 401/403 means the key was revoked or the endpoint * moved) → 503. */ import type { DbClient } from "@coreci/db"; import type { SecretProvider } from "@coreci/secrets"; import type { ChatCompletionRequest, ChatCompletionResponse } from "./types.js"; import { getEndpoint, BYOM_SECRET_NAME } from "./repository.js"; /** Thrown when no validated BYOM endpoint exists for the tenant (REQ-009 → 400). */ export class ByomUnconfiguredError extends Error { constructor(tenantId: string) { super( `BYOM is not configured for tenant ${tenantId}. Configure a BYOM endpoint in the dashboard before sending prompts.`, ); this.name = "ByomUnconfiguredError"; } } /** Thrown when the BYOM endpoint is unreachable or rejected the key (REQ-009, Edge 1 → 503). */ export class ByomUnreachableError extends Error { override readonly cause: unknown | undefined; constructor(message: string, cause?: unknown) { super(message); this.name = "ByomUnreachableError"; this.cause = cause; } } /** * Route an inference call to the tenant's configured BYOM endpoint (REQ-008). * * 1. Resolve the tenant's validated endpoint. If none → ByomUnconfiguredError. * 2. Resolve the API key via secrets.get → unwrap. * 3. POST to `{endpoint.url}/v1/chat/completions` with Authorization: Bearer. * 4. On 200 → return parsed ChatCompletionResponse. * On connection error → ByomUnreachableError. * On 401/403 → ByomUnreachableError (treated as unreachable for the operator). */ export async function routeInference( db: DbClient, secrets: SecretProvider, tenantId: string, payload: ChatCompletionRequest, ): Promise { const endpoint = await getEndpoint(db, tenantId); if (!endpoint) { throw new ByomUnconfiguredError(tenantId); } const key = await secrets.get(tenantId, BYOM_SECRET_NAME); const url = joinChatCompletions(endpoint.url); let res: Response; try { res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${key.unwrap()}`, }, body: JSON.stringify(payload), }); } catch (err) { throw new ByomUnreachableError( `BYOM endpoint at ${endpoint.url} is unreachable: ${err instanceof Error ? err.message : String(err)}`, err, ); } if (res.status === 401 || res.status === 403) { let detail: string; try { detail = await res.text(); } catch { detail = `HTTP ${res.status}`; } throw new ByomUnreachableError( `BYOM endpoint at ${endpoint.url} rejected the API key (HTTP ${res.status}). The key may have been revoked or the endpoint moved. ${detail}`, ); } if (!res.ok) { let detail: string; try { detail = await res.text(); } catch { detail = `HTTP ${res.status}`; } throw new ByomUnreachableError( `BYOM endpoint at ${endpoint.url} returned HTTP ${res.status}: ${detail}`, ); } let body: unknown; try { body = await res.json(); } catch (err) { throw new ByomUnreachableError( `BYOM endpoint at ${endpoint.url} returned 200 but the body was not valid JSON: ${ err instanceof Error ? err.message : String(err) }`, err, ); } return body as ChatCompletionResponse; } /** Join a base URL with `/v1/chat/completions` (mirrors validator.ts). */ function joinChatCompletions(baseUrl: string): string { const trimmed = baseUrl.replace(/\/+$/, ""); if (trimmed.endsWith("/v1/chat/completions")) return trimmed; if (trimmed.endsWith("/v1")) return `${trimmed}/chat/completions`; return `${trimmed}/v1/chat/completions`; }