commit 7ff0ccb160cdcc9bcb227ec34596755c320a2745 Author: jules Date: Fri May 1 22:50:59 2026 +1000 Initial commit Multi-provider LLM picker for Crema apps. Sits on top of @crema/llm-ui and adds: - A named provider catalog (OpenAI, Anthropic, DeepSeek, Qwen, LM Studio) with default base URLs and suggested models. - buildAdapter(): async factory that resolves the API key from a caller-injected resolveSecret() (direct mode) or assembles an ArcadiaProxyAdapter pointing at /api/v1/ai/llm/chat (proxy mode). - LLMProvidersSettingsCard: provider/model picker, vault-key reference field with "looks like an API key" warning, direct/proxy toggle, context/response budgets, system prompt, optional Test connection. - Persisted settings store at crema.llm-providers.settings, reactive via useSyncExternalStore. The lib is provider-agnostic about how keys are stored; the consuming app injects a resolveSecret() that hits whatever vault it owns. arcadia-admin and vibespace both wire this to /api/v1/secrets/:name. Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/README.md b/README.md new file mode 100644 index 0000000..aa79279 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# lib-llm-providers-ui + +Multi-provider LLM picker + settings card for Crema apps. Sits on top of `@crema/llm-ui` and adds: + +- A **named provider catalog** (OpenAI, Anthropic, DeepSeek, Qwen, LM Studio) with sensible default base URLs and model lists. +- An **arcadia-proxy adapter** that routes chat completions through `POST /api/v1/ai/llm/chat`, so API keys never leave the server. +- A **direct-mode adapter** that resolves the API key from arcadia's Secrets Manager (`GET /api/v1/secrets/:name`) and calls the provider directly from the browser. +- A **settings card** (`LLMProvidersSettingsCard`) that ties it all together — provider, model, base-URL override, arcadia-secret name, transport toggle, context/response budgets, system prompt. +- A **persistent settings store** with `useSettings()` / `saveSettings()` / `resetSettings()` and a `crema:llm-providers-change` event for reactive updates across tabs. + +## Quick start + +```tsx +import { + LLMProvidersSettingsCard, + buildAdapter, + useSettings, +} from "@crema/llm-providers-ui" +import { LLMProvider, useChat } from "@crema/llm-ui" +import { useArcadiaClient } from "@crema/arcadia-client" +import { useEffect, useState } from "react" + +function App() { + const arcadia = useArcadiaClient() + const settings = useSettings() + const [adapter, setAdapter] = useState(null) + + useEffect(() => { + buildAdapter({ + settings, + // Direct mode — fetch the API key from arcadia's vault. + resolveSecret: async (name) => { + const res = await arcadia.GET<{ data: { value: string } }>(`/api/v1/secrets/${name}`) + return res.data.value + }, + // Or proxy mode (when the backend endpoint exists): + arcadiaBaseURL: import.meta.env.VITE_ARCADIA_URL, + arcadiaAuthToken: sessionStorage.getItem("arcadia_access_token") ?? undefined, + arcadiaTenantId: import.meta.env.VITE_ARCADIA_TENANT, + }).then(setAdapter) + }, [settings, arcadia]) + + if (!adapter) return null + return ( + + + + ) +} +``` + +For the settings page: + +```tsx + { + // Wire to a one-off completion through buildAdapter. + // Return { ok: true, message: "Connected." } or { ok: false, message: "..." }. + }} +/> +``` + +## Direct vs proxy + +| Mode | Where the key lives at call time | When to use | +|---|---|---| +| `direct` | Briefly in the browser (fetched from arcadia per-call) | When the backend proxy isn't deployed yet, or for local development. | +| `proxy` | Server-side only (read by arcadia, never sent to the client) | Production. Requires `POST /api/v1/ai/llm/chat` on arcadia — see `LLM_PROXY_CONTRACT.md` in the consuming app. | + +The settings card lets the user toggle between them. `buildAdapter()` returns the right adapter automatically. + +## Provider catalog + +Edit `PROVIDERS` in `src/index.tsx` to add more. Each entry is: + +```ts +{ + id: "deepseek", + label: "DeepSeek", + baseURL: "https://api.deepseek.com/v1", + transport: "openai-compatible", // or "anthropic" + requiresKey: true, + defaultModels: ["deepseek-chat", "deepseek-reasoner"], + hint: "OpenAI-compatible. Create a key at platform.deepseek.com.", +} +``` + +Adding a provider with a different shape (e.g. Google AI) means picking the closest existing transport, or extending `@crema/llm-ui` with a new adapter and adding a transport string here. + +## Wiring into a Crema app + +1. Clone this lib as a sibling. +2. Add `@source "../../lib-llm-providers-ui/src";` under `/* CREMA:SOURCES */` in `app/app.css`. +3. Add the path alias under `// CREMA:PATHS` in `tsconfig.json`: + ```json + "@crema/llm-providers-ui": ["../lib-llm-providers-ui/src/index.tsx"], + "@crema/llm-providers-ui/*": ["../lib-llm-providers-ui/src/*"], + ``` +4. `crema add` does this automatically once the manifest knows about the lib. diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..633b200 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,707 @@ +// PURPOSE: Multi-provider LLM catalog + settings UI for Crema apps. Wraps +// @crema/llm-ui adapters with named providers (OpenAI, Anthropic, +// DeepSeek, Qwen, LM Studio), an arcadia-proxy transport for keeping +// API keys server-side, and a settings card tying provider + model +// + arcadia-secret-name together. Persists to localStorage. +// =========================================================================== +// EXPORTS +// Types: Provider, ProviderId, ProviderTransport, TransportMode, +// LLMProvidersSettings, BuildAdapterOptions, ResolveSecret +// Catalog: PROVIDERS, listProviders, getProvider +// Settings: DEFAULT_SETTINGS, loadSettings, saveSettings, resetSettings, +// useSettings +// Adapter: buildAdapter, makeArcadiaProxyAdapter +// UI: LLMProvidersSettingsCard +// =========================================================================== +"use client" + +import { useEffect, useState, useSyncExternalStore } from "react" +import { + AnthropicAdapter, + OpenAICompatibleAdapter, + type AnthropicConfig, + type ChatRequest, + type ChatResponse, + type LLMAdapter, + type OpenAICompatibleConfig, + type StreamEvent, +} from "@crema/llm-ui" + +/* ────────────────────────── Catalog ─────────────────────────── */ + +export type ProviderId = "openai" | "anthropic" | "deepseek" | "qwen" | "lmstudio" +export type ProviderTransport = "openai-compatible" | "anthropic" + +export interface Provider { + id: ProviderId + label: string + baseURL: string + transport: ProviderTransport + /** When false the provider needs no API key (local LM Studio). */ + requiresKey: boolean + /** Suggested models for the model picker. The user can also type a custom one. */ + defaultModels: string[] + hint?: string +} + +export const PROVIDERS: Record = { + openai: { + id: "openai", + label: "OpenAI", + baseURL: "https://api.openai.com/v1", + transport: "openai-compatible", + requiresKey: true, + defaultModels: ["gpt-4o", "gpt-4o-mini", "o1-mini"], + hint: "Uses an `sk-...` key. Create one at platform.openai.com.", + }, + anthropic: { + id: "anthropic", + label: "Anthropic", + baseURL: "https://api.anthropic.com", + transport: "anthropic", + requiresKey: true, + defaultModels: ["claude-opus-4-7", "claude-sonnet-4-6", "claude-haiku-4-5"], + hint: "Uses an `sk-ant-...` key. Create one at console.anthropic.com.", + }, + deepseek: { + id: "deepseek", + label: "DeepSeek", + baseURL: "https://api.deepseek.com/v1", + transport: "openai-compatible", + requiresKey: true, + defaultModels: ["deepseek-chat", "deepseek-reasoner"], + hint: "OpenAI-compatible. Create a key at platform.deepseek.com.", + }, + qwen: { + id: "qwen", + label: "Qwen (DashScope)", + baseURL: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + transport: "openai-compatible", + requiresKey: true, + defaultModels: ["qwen-max", "qwen-plus", "qwen-turbo"], + hint: "OpenAI-compatible mode. Use the international or mainland endpoint.", + }, + lmstudio: { + id: "lmstudio", + label: "LM Studio (local)", + baseURL: "http://localhost:1234/v1", + transport: "openai-compatible", + requiresKey: false, + defaultModels: [], + hint: "Runs locally. No API key needed; just point at the server URL.", + }, +} + +export function listProviders(): Provider[] { + return Object.values(PROVIDERS) +} + +export function getProvider(id: ProviderId): Provider { + return PROVIDERS[id] +} + +/* ────────────────────────── Settings (persisted) ─────────────────────────── */ + +export type TransportMode = "direct" | "proxy" + +export interface LLMProvidersSettings { + providerId: ProviderId + model: string + /** Optional override; defaults to PROVIDERS[id].baseURL when empty. */ + baseURL?: string + /** Name of the arcadia secret holding the API key. */ + secretName?: string + mode: TransportMode + responseBudget: number + contextTokens: number + systemPrompt: string +} + +export const DEFAULT_SYSTEM_PROMPT = + "You are a helpful general-purpose assistant embedded in an app. Handle any request the user makes — writing, brainstorming, code, analysis, casual chat — at the length the task deserves. Use markdown when it helps." + +export const DEFAULT_SETTINGS: LLMProvidersSettings = { + providerId: "lmstudio", + model: "local-model", + mode: "direct", + responseBudget: 1024, + contextTokens: 16000, + systemPrompt: DEFAULT_SYSTEM_PROMPT, +} + +const STORAGE_KEY = "crema.llm-providers.settings" +const CHANGE_EVENT = "crema:llm-providers-change" + +let cached: LLMProvidersSettings | null = null + +function readFromStorage(): LLMProvidersSettings { + if (typeof window === "undefined") return DEFAULT_SETTINGS + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return DEFAULT_SETTINGS + const p = JSON.parse(raw) as Partial + const providerId = + p.providerId && p.providerId in PROVIDERS + ? (p.providerId as ProviderId) + : DEFAULT_SETTINGS.providerId + return { + providerId, + model: typeof p.model === "string" && p.model ? p.model : DEFAULT_SETTINGS.model, + baseURL: typeof p.baseURL === "string" && p.baseURL ? p.baseURL : undefined, + secretName: + typeof p.secretName === "string" && p.secretName ? p.secretName : undefined, + mode: p.mode === "proxy" ? "proxy" : "direct", + responseBudget: + Number.isFinite(p.responseBudget) && (p.responseBudget as number) > 0 + ? (p.responseBudget as number) + : DEFAULT_SETTINGS.responseBudget, + contextTokens: + Number.isFinite(p.contextTokens) && (p.contextTokens as number) > 0 + ? (p.contextTokens as number) + : DEFAULT_SETTINGS.contextTokens, + systemPrompt: + typeof p.systemPrompt === "string" && p.systemPrompt.trim() + ? p.systemPrompt + : DEFAULT_SETTINGS.systemPrompt, + } + } catch { + return DEFAULT_SETTINGS + } +} + +export function loadSettings(): LLMProvidersSettings { + return readFromStorage() +} + +export function saveSettings(next: LLMProvidersSettings): void { + if (typeof window === "undefined") return + localStorage.setItem(STORAGE_KEY, JSON.stringify(next)) + cached = null + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)) +} + +export function resetSettings(): void { + saveSettings(DEFAULT_SETTINGS) +} + +function subscribe(cb: () => void): () => void { + const onChange = () => { + cached = null + cb() + } + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEY) onChange() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", onStorage) + return () => { + window.removeEventListener(CHANGE_EVENT, onChange) + window.removeEventListener("storage", onStorage) + } +} + +function getSnapshot(): LLMProvidersSettings { + if (cached) return cached + cached = readFromStorage() + return cached +} + +function getServerSnapshot(): LLMProvidersSettings { + return DEFAULT_SETTINGS +} + +export function useSettings(): LLMProvidersSettings { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) +} + +/* ────────────────────────── Adapter assembly ─────────────────────────── */ + +/** Resolves an arcadia secret name to its raw value (for direct mode). */ +export type ResolveSecret = (name: string) => Promise + +export interface BuildAdapterOptions { + settings: LLMProvidersSettings + /** Required when mode is "direct" and the provider requires a key. */ + resolveSecret?: ResolveSecret + /** Required when mode is "proxy". E.g. "http://localhost:4000". */ + arcadiaBaseURL?: string + /** Bearer token forwarded to the proxy on the Authorization header. */ + arcadiaAuthToken?: string + /** Sent as X-Tenant-ID on proxy calls. */ + arcadiaTenantId?: string +} + +/** + * Build an LLMAdapter from settings. Async because direct mode may fetch the + * API key from arcadia. + */ +export async function buildAdapter(opts: BuildAdapterOptions): Promise { + const { settings, resolveSecret, arcadiaBaseURL, arcadiaAuthToken, arcadiaTenantId } = opts + const provider = getProvider(settings.providerId) + + if (settings.mode === "proxy") { + if (!arcadiaBaseURL) { + throw new Error("buildAdapter: arcadiaBaseURL is required in proxy mode.") + } + return makeArcadiaProxyAdapter({ + arcadiaBaseURL, + providerId: provider.id, + secretName: settings.secretName, + authToken: arcadiaAuthToken, + tenantId: arcadiaTenantId, + }) + } + + // Direct mode. + let apiKey: string | undefined + if (provider.requiresKey) { + if (!settings.secretName) { + throw new Error( + `buildAdapter: provider "${provider.id}" requires an API key. Set the arcadia secret name in settings.`, + ) + } + if (!resolveSecret) { + throw new Error( + "buildAdapter: resolveSecret is required in direct mode for keyed providers.", + ) + } + apiKey = await resolveSecret(settings.secretName) + } + + const baseURL = settings.baseURL || provider.baseURL + + if (provider.transport === "anthropic") { + const cfg: AnthropicConfig = { apiKey: apiKey ?? "", baseURL } + return new AnthropicAdapter(cfg) + } + + const cfg: OpenAICompatibleConfig = { baseURL, apiKey, label: provider.label } + return new OpenAICompatibleAdapter(cfg) +} + +/* ────────────────────────── Arcadia proxy adapter ─────────────────────────── */ + +export interface ArcadiaProxyAdapterOptions { + arcadiaBaseURL: string + providerId: ProviderId + secretName?: string + authToken?: string + tenantId?: string +} + +/** + * Returns an LLMAdapter that talks to arcadia's `POST /api/v1/ai/llm/chat`. + * + * The proxy uniforms upstream calls into the OpenAI chat-completion shape, so + * we reuse OpenAICompatibleAdapter and just inject `provider` + `secret_name` + * via the per-request `extra` field (which the adapter spreads into the body). + * + * See arcadia-admin/docs/LLM_PROXY_CONTRACT.md for the request/response spec + * the backend must implement. + */ +export function makeArcadiaProxyAdapter(opts: ArcadiaProxyAdapterOptions): LLMAdapter { + const baseURL = opts.arcadiaBaseURL.replace(/\/$/, "") + "/api/v1/ai/llm" + const headers: Record = {} + if (opts.tenantId) headers["X-Tenant-ID"] = opts.tenantId + + const inner = new OpenAICompatibleAdapter({ + baseURL, + apiKey: opts.authToken, // becomes Authorization: Bearer + label: "Arcadia proxy", + headers, + }) + + // Wrap so `provider` and `secret_name` are merged into req.extra. This keeps + // the adapter usable with all the existing useChat/useStream hooks. + const augment = (req: ChatRequest): ChatRequest => ({ + ...req, + extra: { + ...(req.extra ?? {}), + provider: opts.providerId, + ...(opts.secretName ? { secret_name: opts.secretName } : {}), + }, + }) + + return { + id: "arcadia-proxy", + label: "Arcadia proxy", + chat: (req: ChatRequest, signal?: AbortSignal): Promise => + inner.chat(augment(req), signal), + stream: (req: ChatRequest, signal?: AbortSignal): AsyncIterable => + inner.stream(augment(req), signal), + } +} + +/* ────────────────────────── Settings card UI ─────────────────────────── */ + +const labelStyle: React.CSSProperties = { + display: "block", + fontSize: "0.75rem", + fontWeight: 600, + textTransform: "uppercase", + letterSpacing: "0.04em", + color: "var(--muted-foreground)", + marginBottom: "0.375rem", +} + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "0.5rem 0.75rem", + borderRadius: "0.5rem", + border: "1px solid var(--border)", + background: "var(--background)", + color: "var(--foreground)", + fontSize: "0.875rem", + outline: "none", + fontFamily: "inherit", +} + +const sectionStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "0.375rem", +} + +const cardStyle: React.CSSProperties = { + display: "flex", + flexDirection: "column", + gap: "1rem", + padding: "1.25rem", + borderRadius: "0.75rem", + border: "1px solid var(--border)", + background: "var(--card)", + color: "var(--card-foreground)", +} + +const helpStyle: React.CSSProperties = { + fontSize: "0.75rem", + color: "var(--muted-foreground)", + margin: 0, +} + +const buttonBase: React.CSSProperties = { + padding: "0.5rem 0.875rem", + borderRadius: "0.5rem", + fontSize: "0.875rem", + fontWeight: 500, + cursor: "pointer", + fontFamily: "inherit", +} + +const primaryButtonStyle: React.CSSProperties = { + ...buttonBase, + background: "var(--primary)", + color: "var(--primary-foreground)", + border: "1px solid transparent", +} + +export interface LLMProvidersSettingsCardProps { + /** Controlled mode. When omitted the card reads/writes the persisted store. */ + value?: LLMProvidersSettings + onChange?: (next: LLMProvidersSettings) => void + /** Hide the direct/proxy toggle. Useful before the backend proxy is deployed. */ + hideTransportToggle?: boolean + /** Called when "Test connection" is clicked. */ + onTest?: (settings: LLMProvidersSettings) => Promise<{ ok: boolean; message: string }> +} + +export function LLMProvidersSettingsCard({ + value, + onChange, + hideTransportToggle, + onTest, +}: LLMProvidersSettingsCardProps): JSX.Element { + const stored = useSettings() + const settings = value ?? stored + const set = (patch: Partial) => { + const next = { ...settings, ...patch } + if (onChange) onChange(next) + else saveSettings(next) + } + + const provider = getProvider(settings.providerId) + + const [testBusy, setTestBusy] = useState(false) + const [testResult, setTestResult] = useState<{ ok: boolean; text: string } | null>(null) + + return ( +
+
+ + + {provider.hint ?

{provider.hint}

: null} +
+ +
+ + set({ model: e.target.value })} + placeholder={provider.defaultModels[0] ?? "model id"} + data-action="llm-provider-model" + style={inputStyle} + /> + + {provider.defaultModels.map((m) => ( + +
+ +
+ + set({ baseURL: e.target.value || undefined })} + placeholder={provider.baseURL} + data-action="llm-provider-base-url" + style={inputStyle} + /> +
+ + {provider.requiresKey ? ( +
+ + set({ secretName: e.target.value || undefined })} + placeholder={`llm-${provider.id}-api-key`} + data-action="llm-provider-secret-name" + style={inputStyle} + /> +

+ This is a name, not the key itself. Create the secret under{" "} + + /secrets + {" "} + (paste the API key as the Value), then enter the secret's name here. +

+ {looksLikeAnApiKey(settings.secretName) ? ( +

+ ⚠ That looks like an API key, not a secret name. Store the key under /secrets first, + then enter the secret's name here (e.g. llm-{provider.id}-api-key). +

+ ) : null} +
+ ) : null} + + {!hideTransportToggle ? ( +
+ +
+ {(["direct", "proxy"] as const).map((m) => { + const active = settings.mode === m + return ( + + ) + })} +
+

+ {settings.mode === "direct" + ? "Browser fetches the API key from arcadia and calls the provider directly." + : "Calls go through arcadia's /api/v1/ai/llm/chat. Keys never leave the server."} +

+
+ ) : null} + +
+
+ + set({ contextTokens: n })} + dataAction="llm-provider-context" + /> +
+
+ + set({ responseBudget: n })} + dataAction="llm-provider-response-budget" + /> +
+
+ +
+ +