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:
jules
2026-05-01 22:50:59 +10:00
commit 7ff0ccb160
2 changed files with 807 additions and 0 deletions

100
README.md Normal file
View 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
View 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"