feat(transport): add "gateway" mode routing through arcadia-llm-gateway

New TransportMode "gateway" + makeGatewayAdapter: routes chat through
<gatewayURL>/v1/chat/completions with a per-app gateway key (Bearer agw_),
so the gateway resolves the upstream provider (BYO via personal-cloud cred or
managed), REDACTS the body, forwards, and meters. Additive — direct/proxy
unchanged. OpenAI-compatible providers covered; Anthropic /messages (Bearer)
is a follow-up. Audit #11 / #3 foundation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jules
2026-06-21 10:05:59 +10:00
parent 3338593c0c
commit 2ce20e810f

View File

@@ -102,7 +102,7 @@ export function getProvider(id: ProviderId): Provider {
/* ────────────────────────── Settings (persisted) ─────────────────────────── */ /* ────────────────────────── Settings (persisted) ─────────────────────────── */
export type TransportMode = "direct" | "proxy" export type TransportMode = "direct" | "proxy" | "gateway"
export interface LLMProvidersSettings { export interface LLMProvidersSettings {
providerId: ProviderId providerId: ProviderId
@@ -150,7 +150,8 @@ function readFromStorage(): LLMProvidersSettings {
baseURL: typeof p.baseURL === "string" && p.baseURL ? p.baseURL : undefined, baseURL: typeof p.baseURL === "string" && p.baseURL ? p.baseURL : undefined,
secretName: secretName:
typeof p.secretName === "string" && p.secretName ? p.secretName : undefined, typeof p.secretName === "string" && p.secretName ? p.secretName : undefined,
mode: p.mode === "proxy" ? "proxy" : "direct", mode:
p.mode === "proxy" ? "proxy" : p.mode === "gateway" ? "gateway" : "direct",
responseBudget: responseBudget:
Number.isFinite(p.responseBudget) && (p.responseBudget as number) > 0 Number.isFinite(p.responseBudget) && (p.responseBudget as number) > 0
? (p.responseBudget as number) ? (p.responseBudget as number)
@@ -229,6 +230,10 @@ export interface BuildAdapterOptions {
arcadiaAuthToken?: string arcadiaAuthToken?: string
/** Sent as X-Tenant-ID on proxy calls. */ /** Sent as X-Tenant-ID on proxy calls. */
arcadiaTenantId?: string arcadiaTenantId?: string
/** Required when mode is "gateway". Base URL of arcadia-llm-gateway, e.g. "https://llm-gateway.arcadia-dev.sky-ai.com". */
gatewayURL?: string
/** Required when mode is "gateway". Per-app gateway key (agw_...) minted via POST /api/v1/me/keys. */
gatewayKey?: string
} }
/** /**
@@ -236,9 +241,26 @@ export interface BuildAdapterOptions {
* API key from arcadia. * API key from arcadia.
*/ */
export async function buildAdapter(opts: BuildAdapterOptions): Promise<LLMAdapter> { export async function buildAdapter(opts: BuildAdapterOptions): Promise<LLMAdapter> {
const { settings, resolveSecret, arcadiaBaseURL, arcadiaAuthToken, arcadiaTenantId } = opts const {
settings,
resolveSecret,
arcadiaBaseURL,
arcadiaAuthToken,
arcadiaTenantId,
gatewayURL,
gatewayKey,
} = opts
const provider = getProvider(settings.providerId) const provider = getProvider(settings.providerId)
if (settings.mode === "gateway") {
if (!gatewayURL || !gatewayKey) {
throw new Error(
"buildAdapter: gatewayURL and gatewayKey are required in gateway mode.",
)
}
return makeGatewayAdapter({ gatewayURL, gatewayKey, label: provider.label })
}
if (settings.mode === "proxy") { if (settings.mode === "proxy") {
if (!arcadiaBaseURL) { if (!arcadiaBaseURL) {
throw new Error("buildAdapter: arcadiaBaseURL is required in proxy mode.") throw new Error("buildAdapter: arcadiaBaseURL is required in proxy mode.")
@@ -332,6 +354,37 @@ export function makeArcadiaProxyAdapter(opts: ArcadiaProxyAdapterOptions): LLMAd
} }
} }
/* ────────────────────────── Gateway adapter ─────────────────────────── */
export interface GatewayAdapterOptions {
/** Base URL of arcadia-llm-gateway (no trailing /v1). */
gatewayURL: string
/** Per-app gateway key (agw_...). Sent as Authorization: Bearer. */
gatewayKey: string
label?: string
}
/**
* Adapter that routes through arcadia-llm-gateway's OpenAI-compatible proxy at
* `<gatewayURL>/v1/chat/completions`, authenticated by the gateway key. The
* gateway resolves the upstream provider (BYO via the user's personal-cloud
* credential, or managed), REDACTS the request body (arcadia-redaction),
* forwards, and meters — so no provider key or unredacted prompt leaves the app.
*
* Covers OpenAI-compatible providers (deepseek, openai, qwen, lmstudio, …),
* which use the gateway's Bearer-authed /v1/chat/completions route. Anthropic
* native (/v1/messages) also expects Bearer auth at the gateway; a dedicated
* messages adapter is a follow-up.
*/
export function makeGatewayAdapter(opts: GatewayAdapterOptions): LLMAdapter {
const baseURL = opts.gatewayURL.replace(/\/+$/, "") + "/v1"
return new OpenAICompatibleAdapter({
baseURL,
apiKey: opts.gatewayKey, // becomes Authorization: Bearer agw_...
label: opts.label ?? "Arcadia gateway",
})
}
/* ────────────────────────── Settings card UI ─────────────────────────── */ /* ────────────────────────── Settings card UI ─────────────────────────── */
const labelStyle: React.CSSProperties = { const labelStyle: React.CSSProperties = {