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) <noreply@anthropic.com>
This commit is contained in:
100
README.md
Normal file
100
README.md
Normal file
@@ -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 (
|
||||||
|
<LLMProvider adapter={adapter} model={settings.model}>
|
||||||
|
<Chat />
|
||||||
|
</LLMProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For the settings page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<LLMProvidersSettingsCard
|
||||||
|
hideTransportToggle={!proxyAvailable}
|
||||||
|
onTest={async (s) => {
|
||||||
|
// 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.
|
||||||
707
src/index.tsx
Normal file
707
src/index.tsx
Normal file
@@ -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<ProviderId, Provider> = {
|
||||||
|
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<LLMProvidersSettings>
|
||||||
|
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<string>
|
||||||
|
|
||||||
|
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<LLMAdapter> {
|
||||||
|
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<string, string> = {}
|
||||||
|
if (opts.tenantId) headers["X-Tenant-ID"] = opts.tenantId
|
||||||
|
|
||||||
|
const inner = new OpenAICompatibleAdapter({
|
||||||
|
baseURL,
|
||||||
|
apiKey: opts.authToken, // becomes Authorization: Bearer <session token>
|
||||||
|
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<ChatResponse> =>
|
||||||
|
inner.chat(augment(req), signal),
|
||||||
|
stream: (req: ChatRequest, signal?: AbortSignal): AsyncIterable<StreamEvent> =>
|
||||||
|
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<LLMProvidersSettings>) => {
|
||||||
|
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 (
|
||||||
|
<div data-slot="llm-providers-settings-card" style={cardStyle}>
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<label htmlFor="llm-provider" style={labelStyle}>
|
||||||
|
Provider
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="llm-provider"
|
||||||
|
value={settings.providerId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value as ProviderId
|
||||||
|
const p = getProvider(id)
|
||||||
|
set({ providerId: id, model: p.defaultModels[0] ?? settings.model })
|
||||||
|
}}
|
||||||
|
data-action="llm-provider-select"
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
{listProviders().map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{provider.hint ? <p style={helpStyle}>{provider.hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<label htmlFor="llm-model" style={labelStyle}>
|
||||||
|
Model
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="llm-model"
|
||||||
|
list="llm-model-options"
|
||||||
|
value={settings.model}
|
||||||
|
onChange={(e) => set({ model: e.target.value })}
|
||||||
|
placeholder={provider.defaultModels[0] ?? "model id"}
|
||||||
|
data-action="llm-provider-model"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<datalist id="llm-model-options">
|
||||||
|
{provider.defaultModels.map((m) => (
|
||||||
|
<option key={m} value={m} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<label htmlFor="llm-base-url" style={labelStyle}>
|
||||||
|
Base URL <span style={{ fontWeight: 400, textTransform: "none" }}>(override)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="llm-base-url"
|
||||||
|
value={settings.baseURL ?? ""}
|
||||||
|
onChange={(e) => set({ baseURL: e.target.value || undefined })}
|
||||||
|
placeholder={provider.baseURL}
|
||||||
|
data-action="llm-provider-base-url"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{provider.requiresKey ? (
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<label htmlFor="llm-secret-name" style={labelStyle}>
|
||||||
|
Vault key reference
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="llm-secret-name"
|
||||||
|
value={settings.secretName ?? ""}
|
||||||
|
onChange={(e) => set({ secretName: e.target.value || undefined })}
|
||||||
|
placeholder={`llm-${provider.id}-api-key`}
|
||||||
|
data-action="llm-provider-secret-name"
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
<p style={helpStyle}>
|
||||||
|
<strong>This is a name, not the key itself.</strong> Create the secret under{" "}
|
||||||
|
<a href="/secrets" style={{ color: "var(--primary)", textDecoration: "underline" }}>
|
||||||
|
/secrets
|
||||||
|
</a>{" "}
|
||||||
|
(paste the API key as the Value), then enter the secret's <em>name</em> here.
|
||||||
|
</p>
|
||||||
|
{looksLikeAnApiKey(settings.secretName) ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
...helpStyle,
|
||||||
|
color: "var(--destructive)",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚠ 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. <code>llm-{provider.id}-api-key</code>).
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!hideTransportToggle ? (
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<label style={labelStyle}>Transport</label>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||||
|
{(["direct", "proxy"] as const).map((m) => {
|
||||||
|
const active = settings.mode === m
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
onClick={() => set({ mode: m })}
|
||||||
|
data-action={`llm-provider-mode-${m}`}
|
||||||
|
style={{
|
||||||
|
...buttonBase,
|
||||||
|
flex: 1,
|
||||||
|
background: active ? "var(--primary)" : "var(--background)",
|
||||||
|
color: active ? "var(--primary-foreground)" : "var(--foreground)",
|
||||||
|
border: active ? "1px solid transparent" : "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{m === "direct" ? "Direct" : "Arcadia proxy"}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p style={helpStyle}>
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem" }}>
|
||||||
|
<div style={{ ...sectionStyle, flex: 1 }}>
|
||||||
|
<label htmlFor="llm-context" style={labelStyle}>
|
||||||
|
Context tokens
|
||||||
|
</label>
|
||||||
|
<NumberField
|
||||||
|
id="llm-context"
|
||||||
|
min={256}
|
||||||
|
value={settings.contextTokens}
|
||||||
|
onCommit={(n) => set({ contextTokens: n })}
|
||||||
|
dataAction="llm-provider-context"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ ...sectionStyle, flex: 1 }}>
|
||||||
|
<label htmlFor="llm-response-budget" style={labelStyle}>
|
||||||
|
Response budget
|
||||||
|
</label>
|
||||||
|
<NumberField
|
||||||
|
id="llm-response-budget"
|
||||||
|
min={64}
|
||||||
|
value={settings.responseBudget}
|
||||||
|
onCommit={(n) => set({ responseBudget: n })}
|
||||||
|
dataAction="llm-provider-response-budget"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={sectionStyle}>
|
||||||
|
<label htmlFor="llm-system-prompt" style={labelStyle}>
|
||||||
|
System prompt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="llm-system-prompt"
|
||||||
|
value={settings.systemPrompt}
|
||||||
|
onChange={(e) => set({ systemPrompt: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
data-action="llm-provider-system-prompt"
|
||||||
|
style={{ ...inputStyle, resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onTest ? (
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={testBusy}
|
||||||
|
onClick={async () => {
|
||||||
|
setTestBusy(true)
|
||||||
|
setTestResult(null)
|
||||||
|
try {
|
||||||
|
const r = await onTest(settings)
|
||||||
|
setTestResult({ ok: r.ok, text: r.message })
|
||||||
|
} catch (err) {
|
||||||
|
setTestResult({
|
||||||
|
ok: false,
|
||||||
|
text: err instanceof Error ? err.message : "Test failed.",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setTestBusy(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
data-action="llm-provider-test"
|
||||||
|
style={primaryButtonStyle}
|
||||||
|
>
|
||||||
|
{testBusy ? "Testing…" : "Test connection"}
|
||||||
|
</button>
|
||||||
|
{testResult ? (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.8125rem",
|
||||||
|
color: testResult.ok ? "var(--success, #16a34a)" : "var(--destructive)",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testResult.text}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────── Internal helpers ─────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heuristic: does this string look like a raw API key the user mis-pasted into
|
||||||
|
* the "Vault key reference" field? Catches `sk-...`, `sk-ant-...`, and long
|
||||||
|
* hex/base64-ish strings without dashes.
|
||||||
|
*/
|
||||||
|
function looksLikeAnApiKey(s: string | undefined): boolean {
|
||||||
|
if (!s) return false
|
||||||
|
if (/^sk-/i.test(s)) return true
|
||||||
|
if (/^[A-Za-z0-9_]{40,}$/.test(s)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Number input that lets the user clear the field, type freely, and only
|
||||||
|
* commits a value on blur (clamped to `min`). Empty text after blur reverts
|
||||||
|
* to the previously committed value.
|
||||||
|
*/
|
||||||
|
function NumberField({
|
||||||
|
id,
|
||||||
|
min,
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
dataAction,
|
||||||
|
}: {
|
||||||
|
id: string
|
||||||
|
min: number
|
||||||
|
value: number
|
||||||
|
onCommit: (n: number) => void
|
||||||
|
dataAction: string
|
||||||
|
}): JSX.Element {
|
||||||
|
const [text, setText] = useState<string>(String(value))
|
||||||
|
// Sync local text with the committed value when it changes externally
|
||||||
|
// (e.g. settings reset), but don't clobber whatever the user is typing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === "undefined" || document.activeElement?.id !== id) {
|
||||||
|
setText(String(value))
|
||||||
|
}
|
||||||
|
}, [value, id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={min}
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const n = Number(text)
|
||||||
|
if (text.trim() === "" || Number.isNaN(n)) {
|
||||||
|
// Revert to last committed value.
|
||||||
|
setText(String(value))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const clamped = Math.max(min, Math.floor(n))
|
||||||
|
setText(String(clamped))
|
||||||
|
onCommit(clamped)
|
||||||
|
}}
|
||||||
|
data-action={dataAction}
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────── Re-exports ─────────────────────────── */
|
||||||
|
|
||||||
|
export type { ChatRequest, ChatResponse, LLMAdapter, StreamEvent } from "@crema/llm-ui"
|
||||||
Reference in New Issue
Block a user