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:
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user