Files
coreci-chat/packages/byom/src/router.ts
T
CIAgent 3b8345dfe0 feat(P3): Wave C BYOM — endpoint registry, validate-on-save, routing shim
REQ-006: configure BYOM endpoint (URL in DB, key in secret manager)
REQ-007: validate-on-save test inference call (OpenAI-compatible)
REQ-008: route all inference to BYOM (G-001: test-inference proxy endpoint)
REQ-009: reject when unconfigured/unreachable (ByomUnconfiguredError/ByomUnreachableError)

---ci---
phase: 3
milestone: v0.1
status: execute
---/ci---
2026-08-25 01:54:20 +00:00

137 lines
4.9 KiB
TypeScript

/**
* @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<ChatCompletionResponse> {
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`;
}