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