diff --git a/app/lib/arcadia/integrations.ts b/app/lib/arcadia/integrations.ts new file mode 100644 index 0000000..7a77ac7 --- /dev/null +++ b/app/lib/arcadia/integrations.ts @@ -0,0 +1,205 @@ +// Integration-registry API client (operator surface). +// +// Talks to arcadia-llm-gateway's `/api/v1/integrations*` endpoints — the +// platform operator manages pooled/platform credentials at ANY scope and +// inspects cross-tenant usage *metadata* (never secrets; reads return +// `has_secret`, never a value). +// +// The tenant mirror (`/api/v1/me/integrations*`, scoped to the caller) lives +// in arcadia-console. Pure functions over an injected gateway `ArcadiaClient` +// (see `~/lib/gateway`); no app coupling, so this lifts into a shared lib once +// both apps exist. + +import type { ArcadiaClient } from "@crema/arcadia-client" + +export type Scope = "platform" | "tenant" | "app" | "user" | "agent" +export type AuthKind = "bearer_static" | "basic" | "api_key_header" | "oauth2" +export type CredentialSource = "byo" | "pooled" +export type CredentialStatus = "active" | "expired" | "revoked" + +export interface CostModel { + unit?: "call" | "search" | "1k_tokens" + price_usd?: string | number + currency?: string +} + +export interface Constraints { + rate_per_min?: number + monthly_budget_usd?: string | number + monthly_call_cap?: number +} + +export interface Credential { + id: string + integration_id: string + secret_name: string + auth_kind: AuthKind + meta: Record + /** Presence, not value — the secret is write-only. */ + has_secret: boolean + expires_at: string | null + last_rotated_at: string | null + source: CredentialSource + status: CredentialStatus +} + +export interface Integration { + id: string + scope: Scope + scope_id: string | null + provider: string + capability: string | null + display_name: string | null + description: string | null + docs_url: string | null + base_url: string | null + enabled: boolean + cost_model: CostModel + constraints: Constraints + created_by: string | null + credentials: Credential[] +} + +export interface UsageEntry { + integration_id: string + scope: Scope + scope_id: string | null + provider: string + capability: string | null + units: number + cost_usd: string + calls: number +} + +export interface IntegrationInput { + scope?: Scope + scope_id?: string | null + provider: string + capability?: string | null + display_name?: string | null + description?: string | null + docs_url?: string | null + base_url?: string | null + enabled?: boolean + cost_model?: CostModel + constraints?: Constraints +} + +export interface CredentialInput { + secret_name: string + auth_kind: AuthKind + /** Plaintext, write-only — sent on create/rotate, never returned. */ + secret?: string + source?: CredentialSource + expires_at?: string | null + meta?: Record +} + +export interface TestVerdict { + status: string + auth_kind?: AuthKind + policy?: { + within_budget: boolean + within_rate: boolean + remaining_budget_usd: string | null + } +} + +export interface ScopeFilter { + scope?: Scope + scope_id?: string +} + +const BASE = "/api/v1/integrations" + +export async function listIntegrations( + c: ArcadiaClient, + filter: ScopeFilter = {}, +): Promise { + const res = await c.GET<{ integrations: Integration[] }>(BASE, { + params: { scope: filter.scope, scope_id: filter.scope_id }, + }) + return res.integrations +} + +export async function createIntegration( + c: ArcadiaClient, + input: IntegrationInput, +): Promise { + const res = await c.POST<{ integration: Integration }>(BASE, { body: input }) + return res.integration +} + +export async function updateIntegration( + c: ArcadiaClient, + id: string, + input: Partial, +): Promise { + const res = await c.PATCH<{ integration: Integration }>(`${BASE}/${id}`, { body: input }) + return res.integration +} + +export async function deleteIntegration(c: ArcadiaClient, id: string): Promise { + await c.DELETE(`${BASE}/${id}`) +} + +export async function addCredential( + c: ArcadiaClient, + integrationId: string, + input: CredentialInput, +): Promise { + const res = await c.POST<{ credential: Credential }>(`${BASE}/${integrationId}/credentials`, { + body: input, + }) + return res.credential +} + +export async function updateCredential( + c: ArcadiaClient, + credentialId: string, + input: Partial, +): Promise { + const res = await c.PATCH<{ credential: Credential }>(`/api/v1/credentials/${credentialId}`, { + body: input, + }) + return res.credential +} + +export async function deleteCredential(c: ArcadiaClient, credentialId: string): Promise { + await c.DELETE(`/api/v1/credentials/${credentialId}`) +} + +/** + * Run the registry's policy gate against an integration's credential. 200 with + * a verdict; 4xx (409 expired / 429 over-budget / 404 no credential) is thrown + * by `ArcadiaClient` as an `ArcadiaError` — inspect `err.status`. + */ +export async function testIntegration(c: ArcadiaClient, id: string): Promise { + return c.POST(`${BASE}/${id}/test`) +} + +export async function usageSummary( + c: ArcadiaClient, + filter: ScopeFilter = {}, +): Promise { + const res = await c.GET<{ usage: UsageEntry[] }>(`${BASE}/usage`, { + params: { scope: filter.scope, scope_id: filter.scope_id }, + }) + return res.usage +} + +// ── display helpers ──────────────────────────────────────────────────── + +export function formatUsd(value: string | number | null | undefined): string { + if (value === null || value === undefined) return "—" + const n = typeof value === "string" ? Number(value) : value + if (Number.isNaN(n)) return "—" + return `$${n.toFixed(n < 0.01 && n > 0 ? 4 : 2)}` +} + +export function credentialHealth(cred: Credential): "ok" | "expired" | "revoked" | "missing" { + if (cred.status === "revoked") return "revoked" + if (!cred.has_secret) return "missing" + if (cred.expires_at && new Date(cred.expires_at).getTime() < Date.now()) return "expired" + return "ok" +} diff --git a/app/lib/gateway.ts b/app/lib/gateway.ts new file mode 100644 index 0000000..471d934 --- /dev/null +++ b/app/lib/gateway.ts @@ -0,0 +1,38 @@ +// Arcadia LLM-gateway client. +// +// The integration registry lives on arcadia-llm-gateway, not arcadia-app, so +// it needs its own ArcadiaClient pointed at a different base URL. Everything +// else is identical to the arcadia-app client: the same access token (the +// gateway validates arcadia-app JWTs via the shared Guardian secret) and the +// same 401 cleanup. The gateway's CORS already allows localhost + any +// *.sky-ai.com origin, so the browser calls it directly. + +import { createArcadiaClient, type ArcadiaClient } from "@crema/arcadia-client" + +const GATEWAY_URL = import.meta.env.VITE_LLM_GATEWAY_URL ?? "http://localhost:4015" + +const ACCESS_TOKEN_KEY = "arcadia_access_token" +const REFRESH_TOKEN_KEY = "arcadia_refresh_token" + +let client: ArcadiaClient | null = null + +export function gatewayClient(): ArcadiaClient { + if (!client) { + client = createArcadiaClient({ + baseUrl: GATEWAY_URL, + getToken: () => + typeof window === "undefined" ? null : sessionStorage.getItem(ACCESS_TOKEN_KEY), + onUnauthorized: () => { + if (typeof window !== "undefined") { + sessionStorage.removeItem(ACCESS_TOKEN_KEY) + sessionStorage.removeItem(REFRESH_TOKEN_KEY) + } + }, + }) + } + return client +} + +export function useGatewayClient(): ArcadiaClient { + return gatewayClient() +} diff --git a/app/routes/integrations.tsx b/app/routes/integrations.tsx new file mode 100644 index 0000000..d1bc374 --- /dev/null +++ b/app/routes/integrations.tsx @@ -0,0 +1,632 @@ +// Integrations (operator) — platform/pooled external-API arrangements across +// every scope, backed by the integration registry on arcadia-llm-gateway +// (`/api/v1/integrations*`). The operator manages pooled credentials and +// inspects cross-tenant usage metadata; secrets are write-only. + +import { useCallback, useEffect, useMemo, useState } from "react" +import { + AlertTriangle, + CheckCircle2, + FlaskConical, + KeyRound, + Pencil, + Plug, + Plus, + Trash2, +} from "lucide-react" +import { ArcadiaError } from "@crema/arcadia-client" + +import { AppShell } from "~/components/layout/app-shell" +import { Badge } from "~/components/ui/badge" +import { Button } from "~/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog" +import { Input } from "~/components/ui/input" +import { Label } from "~/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select" +import { Switch } from "~/components/ui/switch" +import { useGatewayClient } from "~/lib/gateway" +import { + addCredential, + createIntegration, + credentialHealth, + deleteIntegration, + formatUsd, + listIntegrations, + testIntegration, + updateIntegration, + usageSummary, + type AuthKind, + type Integration, + type Scope, + type UsageEntry, +} from "~/lib/arcadia/integrations" + +const AUTH_KINDS: AuthKind[] = ["bearer_static", "api_key_header", "basic", "oauth2"] +const SCOPES: Scope[] = ["platform", "tenant", "app", "user", "agent"] +const SCOPE_FILTERS: Array = ["all", ...SCOPES] + +type Form = { + scope: Scope + scope_id: string + provider: string + capability: string + display_name: string + unit: string + price_usd: string + monthly_budget_usd: string + secret_name: string + auth_kind: AuthKind + secret: string + pooled: boolean +} + +const emptyForm: Form = { + scope: "platform", + scope_id: "", + provider: "", + capability: "", + display_name: "", + unit: "call", + price_usd: "", + monthly_budget_usd: "", + secret_name: "", + auth_kind: "bearer_static", + secret: "", + pooled: true, +} + +export default function IntegrationsRoute() { + const gw = useGatewayClient() + const [items, setItems] = useState([]) + const [usage, setUsage] = useState([]) + const [scopeFilter, setScopeFilter] = useState("all") + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [editing, setEditing] = useState(null) + const [tests, setTests] = useState>({}) + + const refresh = useCallback(async () => { + setError(null) + const filter = scopeFilter === "all" ? {} : { scope: scopeFilter } + try { + const [list, use] = await Promise.all([ + listIntegrations(gw, filter), + usageSummary(gw, filter).catch(() => [] as UsageEntry[]), + ]) + setItems(list) + setUsage(use) + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load integrations.") + } finally { + setLoading(false) + } + }, [gw, scopeFilter]) + + useEffect(() => { + void refresh() + }, [refresh]) + + const usageById = useMemo( + () => new Map(usage.map((u) => [u.integration_id, u] as const)), + [usage], + ) + + const runTest = useCallback( + async (it: Integration) => { + setTests((t) => ({ ...t, [it.id]: { ok: true, message: "Testing…" } })) + try { + const verdict = await testIntegration(gw, it.id) + const remaining = verdict.policy?.remaining_budget_usd + setTests((t) => ({ + ...t, + [it.id]: { + ok: true, + message: + verdict.status === "ok" + ? `OK — within budget & rate${remaining ? ` (${formatUsd(remaining)} left)` : ""}` + : verdict.status, + }, + })) + } catch (e) { + const msg = + e instanceof ArcadiaError + ? e.status === 409 + ? "Credential expired — rotate it" + : e.status === 429 + ? "Over budget / rate limit" + : e.status === 404 + ? "No credential to test" + : e.message + : "Test failed" + setTests((t) => ({ ...t, [it.id]: { ok: false, message: msg } })) + } + }, + [gw], + ) + + const toggleEnabled = useCallback( + async (it: Integration, enabled: boolean) => { + setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled } : x))) + try { + await updateIntegration(gw, it.id, { enabled }) + } catch { + setItems((xs) => xs.map((x) => (x.id === it.id ? { ...x, enabled: !enabled } : x))) + } + }, + [gw], + ) + + const remove = useCallback( + async (it: Integration) => { + if (!window.confirm(`Delete ${it.display_name || it.provider} and its credentials?`)) return + await deleteIntegration(gw, it.id) + await refresh() + }, + [gw, refresh], + ) + + return ( + +
+
+
+ +
+
+

Integrations

+

+ Platform & pooled external-API credentials across every scope. + Keys are stored encrypted and never shown; usage is metadata only. +

+
+
+
+ + +
+
+ + {error ? ( + + + Couldn’t load integrations + {error} + + + ) : loading ? ( +

Loading…

+ ) : items.length === 0 ? ( + + + No integrations in this scope + + Register a platform/pooled arrangement — a shared key the platform + meters and bills to tenants who opt in. + + + + + + + ) : ( +
+ {items.map((it) => { + const u = usageById.get(it.id) + const test = tests[it.id] + return ( + + +
+
+ + {it.display_name || it.provider} + {it.scope} + {it.scope_id ? ( + + {it.scope_id} + + ) : null} + {it.capability ? ( + {it.capability} + ) : null} + + + {it.provider} + {it.cost_model?.price_usd + ? ` · ${formatUsd(it.cost_model.price_usd)}/${it.cost_model.unit ?? "call"}` + : ""} + {it.constraints?.monthly_budget_usd + ? ` · budget ${formatUsd(it.constraints.monthly_budget_usd)}/mo` + : ""} + +
+
+ + toggleEnabled(it, v)} + /> +
+
+
+ +
+ {it.credentials.length === 0 ? ( +

No credential set.

+ ) : ( + it.credentials.map((cred) => { + const health = credentialHealth(cred) + return ( +
+ + {cred.secret_name} + {cred.source} + + {cred.expires_at ? ( + + expires {new Date(cred.expires_at).toLocaleDateString()} + + ) : null} +
+ ) + }) + )} +
+ +

+ {u ? `${u.calls} calls · ${formatUsd(u.cost_usd)} this month` : "No usage yet"} +

+ + {test ? ( +

+ {test.message} +

+ ) : null} + +
+ + + +
+
+
+ ) + })} +
+ )} + + {editing ? ( + setEditing(null)} + onSaved={async () => { + setEditing(null) + await refresh() + }} + /> + ) : null} +
+ ) +} + +function HealthBadge({ health }: { health: ReturnType }) { + if (health === "ok") + return ( + + healthy + + ) + const label = health === "missing" ? "no secret" : health + return ( + + {label} + + ) +} + +function IntegrationDialog({ + mode, + initial, + onClose, + onSaved, +}: { + mode: "new" | "edit" + initial: Integration | null + onClose: () => void + onSaved: () => void | Promise +}) { + const gw = useGatewayClient() + const [form, setForm] = useState
(() => + initial + ? { + ...emptyForm, + scope: initial.scope, + scope_id: initial.scope_id ?? "", + provider: initial.provider, + capability: initial.capability ?? "", + display_name: initial.display_name ?? "", + unit: initial.cost_model?.unit ?? "call", + price_usd: initial.cost_model?.price_usd?.toString() ?? "", + monthly_budget_usd: initial.constraints?.monthly_budget_usd?.toString() ?? "", + } + : emptyForm, + ) + const [saving, setSaving] = useState(false) + const [err, setErr] = useState(null) + const set = (patch: Partial) => setForm((f) => ({ ...f, ...patch })) + + const needsScopeId = form.scope !== "platform" + + const submit = async () => { + setSaving(true) + setErr(null) + try { + const cost_model = form.price_usd + ? { unit: form.unit as "call" | "search" | "1k_tokens", price_usd: form.price_usd, currency: "USD" } + : undefined + const constraints = form.monthly_budget_usd + ? { monthly_budget_usd: form.monthly_budget_usd } + : undefined + + if (mode === "edit" && initial) { + await updateIntegration(gw, initial.id, { + provider: form.provider.trim(), + capability: form.capability.trim() || undefined, + display_name: form.display_name.trim() || undefined, + cost_model, + constraints, + }) + } else { + const created = await createIntegration(gw, { + scope: form.scope, + scope_id: needsScopeId ? form.scope_id.trim() || undefined : undefined, + provider: form.provider.trim(), + capability: form.capability.trim() || undefined, + display_name: form.display_name.trim() || undefined, + cost_model, + constraints, + }) + if (form.secret_name.trim() && form.secret.trim()) { + await addCredential(gw, created.id, { + secret_name: form.secret_name.trim(), + auth_kind: form.auth_kind, + secret: form.secret, + source: form.pooled ? "pooled" : "byo", + }) + } + } + await onSaved() + } catch (e) { + setErr(e instanceof Error ? e.message : "Save failed.") + } finally { + setSaving(false) + } + } + + return ( + (!o ? onClose() : undefined)}> + + + {mode === "new" ? "Add integration" : "Edit integration"} + + Register an external-API arrangement. Platform scope = a pooled key + the platform meters and bills. + + + +
+ {mode === "new" ? ( +
+ + + + + set({ scope_id: e.target.value })} + disabled={!needsScopeId} + placeholder={needsScopeId ? "acme" : "—"} + /> + +
+ ) : null} + + + set({ provider: e.target.value })} + placeholder="tavily" + /> + + + set({ capability: e.target.value })} + placeholder="web_search" + /> + + + set({ display_name: e.target.value })} + /> + +
+ + set({ price_usd: e.target.value })} + placeholder="0.01" + /> + + + + +
+ + set({ monthly_budget_usd: e.target.value })} + placeholder="500" + /> + + + {mode === "new" ? ( +
+

Credential (optional)

+ + set({ secret_name: e.target.value })} + placeholder="tavily_default" + /> + +
+ + + + +
+ set({ pooled: v })} + /> + +
+
+
+ + set({ secret: e.target.value })} + placeholder="sk-…" + /> + +
+ ) : null} + + {err ?

{err}

: null} +
+ + + + + +
+
+ ) +} + +function Field({ + label, + hint, + children, +}: { + label: string + hint?: string + children: React.ReactNode +}) { + return ( +
+ + {children} + {hint ?

{hint}

: null} +
+ ) +}