3b8345dfe0
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---
137 lines
4.9 KiB
TypeScript
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`;
|
|
} |