diff --git a/README.md b/README.md
index 3f2d256..d575d16 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,31 @@ https://relay.byokrelay.com
Free to use. Open CORS (any origin). [Health check →](https://relay.byokrelay.com/health)
+## Svelte stores
+
+```bash
+npm install @byok-relay/svelte
+```
+
+```svelte
+
+
+{#if $chat.isStreaming}
+
{$chat.streamingContent}▋
+
+{/if}
+```
+
+Four stores: `createByokRelayStore` · `createChatStore` · `createStreamingChatStore` · `createRelayHealthStore`. SvelteKit SSR-safe. See [`packages/svelte`](./packages/svelte).
+
## For AI coding agents
If you're using a coding agent (Cursor, Claude Code, Copilot, Codex, etc.), install the skill and let it handle the integration:
diff --git a/llms.txt b/llms.txt
index fb7ae21..7421267 100644
--- a/llms.txt
+++ b/llms.txt
@@ -73,6 +73,62 @@ const TOKEN = await fetch('https://your-relay.com/users', {
Apache 2.0 — free to self-host or build on top of.
+## Svelte Stores (`@byok-relay/svelte`)
+
+For Svelte and SvelteKit apps:
+
+```bash
+npm install @byok-relay/svelte
+```
+
+```svelte
+
+
+```
+
+Stores: `createByokRelayStore` · `createChatStore` · `createStreamingChatStore` · `createRelayHealthStore`.
+SSR-safe for SvelteKit. npm: https://www.npmjs.com/package/@byok-relay/svelte
+
+## React Hooks (`@byok-relay/react`)
+
+```bash
+npm install @byok-relay/react
+```
+
+Hooks: `useByokRelay` · `useChat` · `useStreamingChat` · `useRelayHealth`.
+npm: https://www.npmjs.com/package/@byok-relay/react
+
+## Vue Composables (`@byok-relay/vue`)
+
+```bash
+npm install @byok-relay/vue
+```
+
+Composables: `useByokRelay` · `useChat` · `useStreamingChat` · `useRelayHealth`.
+npm: https://www.npmjs.com/package/@byok-relay/vue
+
+## MCP Server (`@byok-relay/mcp`)
+
+For Claude Desktop, Claude Code, Cursor, Windsurf:
+
+```bash
+npx -y @byok-relay/mcp
+```
+
+Tools: `byok_relay_health` · `byok_relay_register` · `byok_relay_store_key` · `byok_relay_request` · `byok_relay_chat` · `byok_relay_stats`.
+npm: https://www.npmjs.com/package/@byok-relay/mcp
+
+## API Reference
+
+OpenAPI 3.0 spec (machine-readable): https://relay.byokrelay.com/openapi.json
+YAML: https://relay.byokrelay.com/openapi.yaml
+
## Links
- GitHub: https://github.com/avikalpg/byok-relay
diff --git a/package.json b/package.json
index 747063c..76b8595 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "byok-relay",
"version": "1.0.1",
- "description": "BYOK relay server — stores user API keys encrypted and proxies requests to AI providers",
+ "description": "BYOK relay server \u2014 stores user API keys encrypted and proxies requests to AI providers",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js",
@@ -19,5 +19,8 @@
"engines": {
"node": ">=18"
},
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "workspaces": [
+ "packages/*"
+ ]
}
diff --git a/packages/svelte/README.md b/packages/svelte/README.md
new file mode 100644
index 0000000..b6f0c1b
--- /dev/null
+++ b/packages/svelte/README.md
@@ -0,0 +1,376 @@
+# @byok-relay/svelte
+
+> Svelte stores for [byok-relay](https://byokrelay.com) — drop-in BYOK AI in any Svelte or SvelteKit app.
+
+[](https://www.npmjs.com/package/@byok-relay/svelte)
+[](../../LICENSE)
+
+**Let your users bring their own OpenAI/Anthropic/Groq API keys. Zero backend code. Full streaming.**
+
+---
+
+## Install
+
+```bash
+npm install @byok-relay/svelte
+```
+
+Or use the managed relay without installing anything — just import from a CDN:
+
+```html
+
+```
+
+---
+
+## Quick start
+
+```svelte
+
+
+
+{#if $relay.isRegistered}
+
+
+ {#if saved}Key saved ✓
{/if}
+{:else}
+
+{/if}
+{#if $relay.error}{$relay.error}
{/if}
+```
+
+---
+
+## Stores
+
+### `createByokRelayStore(opts)`
+
+Core store — manages relay token registration and encrypted API key storage.
+
+**Options**
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `relayUrl` | `string` | `https://relay.byokrelay.com` | Relay base URL (use your own for self-hosted) |
+| `appId` | `string` | required | App identifier — used to namespace tokens in localStorage |
+
+**State** (`$relay`)
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `token` | `string \| null` | Relay token (persisted in localStorage) |
+| `isRegistered` | `boolean` | `true` when token is present |
+| `error` | `string \| null` | Last error message, or `null` |
+
+**Methods**
+
+| Method | Description |
+|--------|-------------|
+| `relay.register()` | Register a new relay token (or reload from localStorage) |
+| `relay.storeKey(provider, apiKey)` | Store an encrypted API key for a provider |
+| `relay.deleteKey(provider)` | Delete the stored key for a provider |
+| `relay.listProviders()` | Returns `string[]` of providers with stored keys |
+| `relay.logout()` | Clear token and state (does not delete stored keys) |
+
+---
+
+### `createChatStore(opts)`
+
+Non-streaming chat — stateful message list for any supported provider.
+
+**Options**
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `relayUrl` | `https://relay.byokrelay.com` | Relay base URL |
+| `appId` | required | App identifier |
+| `provider` | `'openai'` | AI provider |
+| `model` | provider default | Model override |
+| `systemPrompt` | `undefined` | System prompt |
+| `extraParams` | `{}` | Extra body params forwarded on every request |
+
+**State** (`$chat`)
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `messages` | `Array<{role, content}>` | Full conversation history |
+| `loading` | `boolean` | `true` while request is in-flight |
+| `error` | `string \| null` | Last error message |
+
+**Methods**
+
+| Method | Description |
+|--------|-------------|
+| `chat.send(content, opts?)` | Send a user message; appends assistant reply when done |
+| `chat.clear()` | Reset messages, loading, error |
+
+**Full example**
+
+```svelte
+
+
+
+ {#each $chat.messages as msg}
+
{msg.content}
+ {/each}
+ {#if $chat.loading}
Thinking…
{/if}
+ {#if $chat.error}
{$chat.error}
{/if}
+
+
+
+```
+
+---
+
+### `createStreamingChatStore(opts)`
+
+SSE streaming chat — live token streaming with AbortController cancel support.
+
+Same options as `createChatStore`.
+
+**State** (`$chat`)
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `messages` | `Array<{role, content}>` | Completed messages |
+| `streamingContent` | `string` | Live content being streamed (empty when not streaming) |
+| `isStreaming` | `boolean` | `true` while SSE stream is open |
+| `error` | `string \| null` | Last error message |
+
+**Methods**
+
+| Method | Description |
+|--------|-------------|
+| `chat.send(content, opts?)` | Send a user message; streams reply in real time |
+| `chat.stopStreaming()` | Abort the active stream (partial reply committed to `messages`) |
+| `chat.clear()` | Stop streaming + reset all state |
+
+**Full example**
+
+```svelte
+
+
+
+ {#each $chat.messages as msg}
+
{msg.content}
+ {/each}
+
+ {#if $chat.isStreaming}
+
+ {$chat.streamingContent}▋
+
+
+ {/if}
+
+ {#if $chat.error}
{$chat.error}
{/if}
+
+
+
+```
+
+---
+
+### `createRelayHealthStore(opts)`
+
+Polls `/health` on the relay — tracks liveness and readiness.
+
+**Options**
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `relayUrl` | `https://relay.byokrelay.com` | Relay base URL |
+| `pollIntervalMs` | `30000` | Polling interval ms (`0` = no polling) |
+| `deep` | `false` | If `true`, also pings upstream provider |
+| `provider` | `undefined` | Provider to deep-check (e.g. `'openai'`) |
+
+**State** (`$health`)
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `status` | `string` | `'ok'` \| `'error'` \| `'unreachable'` \| `'unknown'` |
+| `ok` | `boolean` | `true` when relay is healthy |
+| `uptime` | `number \| null` | Relay process uptime in seconds |
+| `warnings` | `string[]` | Non-fatal warnings from the relay |
+| `error` | `string \| null` | Network error message |
+
+**Methods**
+
+| Method | Description |
+|--------|-------------|
+| `health.refetch()` | Manually trigger a health check |
+| `health.destroy()` | Stop the polling timer (call in `onDestroy`) |
+
+**Example**
+
+```svelte
+
+
+
+ {$health.ok ? '● Relay live' : '● Relay down'}
+
+{#if $health.warnings.length}
+ {#each $health.warnings as w}- {w}
{/each}
+{/if}
+```
+
+---
+
+## Supported providers
+
+| Provider | `provider` value | Notes |
+|----------|-----------------|-------|
+| OpenAI | `'openai'` | GPT-4o, GPT-4o-mini, o1, o3, … |
+| Anthropic | `'anthropic'` | Claude 3, Claude 3.5, Claude 4, … |
+| Groq | `'groq'` | Llama 3, Mixtral, … |
+| Mistral | `'mistral'` | Mistral 7B, Mixtral, … |
+| OpenRouter | `'openrouter'` | Any model via OpenRouter |
+| Google | `'google'` | Gemini (pass model as `models/gemini-pro`) |
+
+---
+
+## SvelteKit usage
+
+All stores are SSR-safe. localStorage access is guarded by `typeof window !== 'undefined'`.
+
+**+page.svelte with onMount**
+
+```svelte
+
+```
+
+**Self-hosted relay** (set `relayUrl` to your own instance):
+
+```svelte
+
+```
+
+---
+
+## Svelte 5 / Runes
+
+These stores work unchanged in Svelte 5 via the `$` auto-subscription syntax.
+For Svelte 5 rune-based reactivity, wrap with `$derived`:
+
+```svelte
+
+```
+
+---
+
+## Self-hosting
+
+To run your own relay:
+
+```bash
+docker run -p 3000:3000 \
+ -e ENCRYPTION_SECRET=your-secret-min-32-chars \
+ -e ALLOWED_ORIGINS=https://yourapp.com \
+ ghcr.io/avikalpg/byok-relay
+```
+
+Then pass `relayUrl: 'http://localhost:3000'` to each store factory.
+
+See [byok-relay](https://github.com/avikalpg/byok-relay) for full self-hosting docs.
+
+---
+
+## Related packages
+
+| Package | Description |
+|---------|-------------|
+| [`@byok-relay/client`](https://www.npmjs.com/package/@byok-relay/client) | Vanilla JS / Node.js client |
+| [`@byok-relay/react`](https://www.npmjs.com/package/@byok-relay/react) | React hooks |
+| [`@byok-relay/vue`](https://www.npmjs.com/package/@byok-relay/vue) | Vue 3 composables |
+| [`@byok-relay/mcp`](https://www.npmjs.com/package/@byok-relay/mcp) | MCP server for Claude Desktop |
+
+---
+
+If this saved you time, consider [⭐ starring the repo](https://github.com/avikalpg/byok-relay).
diff --git a/packages/svelte/package.json b/packages/svelte/package.json
new file mode 100644
index 0000000..a15c135
--- /dev/null
+++ b/packages/svelte/package.json
@@ -0,0 +1,54 @@
+{
+ "name": "@byok-relay/svelte",
+ "version": "1.0.0",
+ "description": "Svelte stores for byok-relay — drop-in BYOK AI in any Svelte or SvelteKit app",
+ "keywords": [
+ "byok",
+ "ai",
+ "svelte",
+ "sveltekit",
+ "stores",
+ "openai",
+ "anthropic",
+ "llm",
+ "relay",
+ "frontend",
+ "lovable",
+ "bolt",
+ "no-backend",
+ "api-key",
+ "self-hosted"
+ ],
+ "main": "src/index.js",
+ "module": "src/index.js",
+ "exports": {
+ ".": "./src/index.js"
+ },
+ "files": [
+ "src",
+ "README.md"
+ ],
+ "scripts": {
+ "test": "node test/stores.test.js"
+ },
+ "peerDependencies": {
+ "svelte": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "svelte": {
+ "optional": true
+ }
+ },
+ "devDependencies": {},
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/avikalpg/byok-relay.git",
+ "directory": "packages/svelte"
+ },
+ "homepage": "https://byokrelay.com",
+ "bugs": {
+ "url": "https://github.com/avikalpg/byok-relay/issues"
+ },
+ "license": "MIT",
+ "author": "avikalpg"
+}
diff --git a/packages/svelte/src/index.js b/packages/svelte/src/index.js
new file mode 100644
index 0000000..17f8757
--- /dev/null
+++ b/packages/svelte/src/index.js
@@ -0,0 +1,610 @@
+/**
+ * @byok-relay/svelte
+ *
+ * Svelte stores for byok-relay — drop-in BYOK AI in any Svelte or SvelteKit app.
+ *
+ * Usage:
+ * import {
+ * createByokRelayStore,
+ * createChatStore,
+ * createStreamingChatStore,
+ * createRelayHealthStore
+ * } from '@byok-relay/svelte';
+ *
+ * No build step required. Svelte peer dep optional — stores work in plain JS too.
+ * For SvelteKit SSR: stores are browser-safe (localStorage guarded by `typeof window`).
+ */
+
+'use strict';
+
+// ─── Constants ───────────────────────────────────────────────────────────────
+
+const DEFAULT_RELAY_URL = 'https://relay.byokrelay.com';
+
+const PROVIDER_PATHS = {
+ openai: 'chat/completions',
+ anthropic: 'messages',
+ google: 'models/{model}:generateContent',
+ groq: 'chat/completions',
+ mistral: 'chat/completions',
+ openrouter: 'chat/completions',
+};
+
+// ─── Store factory helpers ────────────────────────────────────────────────────
+
+/**
+ * Create a minimal Svelte-compatible writable store.
+ * Works with Svelte's `$store` auto-subscription syntax and plain JS `.subscribe()`.
+ */
+function writable(initial) {
+ // Try to use Svelte's writable if available (tree-shaken out when not bundled with Svelte)
+ if (typeof globalThis !== 'undefined' && globalThis.__svelteStoreWritable) {
+ return globalThis.__svelteStoreWritable(initial);
+ }
+
+ let value = initial;
+ const subscribers = new Set();
+
+ function subscribe(run, invalidate = () => {}) {
+ subscribers.add(run);
+ run(value);
+ return () => subscribers.delete(run);
+ }
+
+ function set(newValue) {
+ value = newValue;
+ subscribers.forEach(run => run(value));
+ }
+
+ function update(fn) {
+ set(fn(value));
+ }
+
+ function get() {
+ return value;
+ }
+
+ return { subscribe, set, update, get };
+}
+
+// ─── Storage helpers ──────────────────────────────────────────────────────────
+
+function isBrowser() {
+ return typeof window !== 'undefined' && typeof localStorage !== 'undefined';
+}
+
+function storageGet(key) {
+ try { return isBrowser() ? localStorage.getItem(key) : null; }
+ catch { return null; }
+}
+
+function storageSet(key, value) {
+ try { if (isBrowser()) localStorage.setItem(key, value); }
+ catch { /* ignore */ }
+}
+
+function storageRemove(key) {
+ try { if (isBrowser()) localStorage.removeItem(key); }
+ catch { /* ignore */ }
+}
+
+// ─── SSE parsing ─────────────────────────────────────────────────────────────
+
+function* parseSSE(chunk) {
+ const lines = chunk.split('\n');
+ for (const line of lines) {
+ if (!line.startsWith('data:')) continue;
+ const data = line.slice(5).trim();
+ if (data === '[DONE]') { yield null; continue; }
+ try { yield JSON.parse(data); } catch { /* skip malformed */ }
+ }
+}
+
+function extractDelta(event, provider) {
+ if (!event) return '';
+ if (provider === 'anthropic') {
+ if (event.type === 'content_block_delta') return event.delta?.text || '';
+ return '';
+ }
+ // OpenAI-style
+ return event.choices?.[0]?.delta?.content || '';
+}
+
+// ─── createByokRelayStore ─────────────────────────────────────────────────────
+
+/**
+ * Core store — manages relay token registration and API key CRUD.
+ *
+ * @param {object} opts
+ * @param {string} [opts.relayUrl] Relay base URL (default: https://relay.byokrelay.com)
+ * @param {string} opts.appId Your app identifier (used for token namespacing in localStorage)
+ *
+ * @returns {{
+ * subscribe: Function, // Svelte store subscribe — state: { token, isRegistered, error }
+ * register: () => Promise,
+ * storeKey: (provider: string, apiKey: string) => Promise,
+ * deleteKey: (provider: string) => Promise,
+ * listProviders: () => Promise,
+ * logout: () => void
+ * }}
+ *
+ * @example
+ * // +page.svelte
+ *
+ * {#if $relay.isRegistered}
+ * Connected ✓
+ * {:else}
+ *
+ * {/if}
+ * {#if $relay.error}{$relay.error}
{/if}
+ */
+function createByokRelayStore({ relayUrl = DEFAULT_RELAY_URL, appId } = {}) {
+ const tokenKey = `byok_relay_token_${appId}`;
+ const storedToken = storageGet(tokenKey);
+
+ const store = writable({
+ token: storedToken,
+ isRegistered: Boolean(storedToken),
+ error: null,
+ });
+
+ function _patch(patch) {
+ store.update(s => ({ ...s, ...patch }));
+ }
+
+ function _getToken() {
+ return store.get().token;
+ }
+
+ async function register() {
+ _patch({ error: null });
+ try {
+ const res = await fetch(`${relayUrl}/users`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ app_id: appId }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || `Registration failed (${res.status})`);
+ const token = data.token;
+ storageSet(tokenKey, token);
+ _patch({ token, isRegistered: true, error: null });
+ } catch (err) {
+ _patch({ error: err.message });
+ throw err;
+ }
+ }
+
+ async function storeKey(provider, apiKey) {
+ const token = _getToken();
+ if (!token) throw new Error('Not registered — call register() first');
+ _patch({ error: null });
+ try {
+ const res = await fetch(`${relayUrl}/keys/${provider}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ api_key: apiKey }),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || `Failed to store key (${res.status})`);
+ } catch (err) {
+ _patch({ error: err.message });
+ throw err;
+ }
+ }
+
+ async function deleteKey(provider) {
+ const token = _getToken();
+ if (!token) throw new Error('Not registered');
+ _patch({ error: null });
+ try {
+ const res = await fetch(`${relayUrl}/keys/${provider}`, {
+ method: 'DELETE',
+ headers: { 'Authorization': `Bearer ${token}` },
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || `Failed to delete key (${res.status})`);
+ }
+ } catch (err) {
+ _patch({ error: err.message });
+ throw err;
+ }
+ }
+
+ async function listProviders() {
+ const token = _getToken();
+ if (!token) return [];
+ try {
+ const res = await fetch(`${relayUrl}/keys`, {
+ headers: { 'Authorization': `Bearer ${token}` },
+ });
+ if (!res.ok) return [];
+ const data = await res.json();
+ return data.providers || [];
+ } catch {
+ return [];
+ }
+ }
+
+ function logout() {
+ storageRemove(tokenKey);
+ _patch({ token: null, isRegistered: false, error: null });
+ }
+
+ return {
+ subscribe: store.subscribe,
+ register,
+ storeKey,
+ deleteKey,
+ listProviders,
+ logout,
+ };
+}
+
+// ─── createChatStore ──────────────────────────────────────────────────────────
+
+/**
+ * Non-streaming chat store — stateful message list for any provider.
+ *
+ * @param {object} opts
+ * @param {string} [opts.relayUrl] Relay base URL
+ * @param {string} opts.appId App identifier
+ * @param {string} [opts.provider] Default provider ('openai' | 'anthropic' | 'groq' | 'mistral' | 'openrouter')
+ * @param {string} [opts.model] Default model override
+ * @param {string} [opts.systemPrompt] System prompt
+ * @param {object} [opts.extraParams] Extra body params forwarded on every request
+ *
+ * @returns {{
+ * subscribe: Function, // state: { messages, loading, error }
+ * send: (content: string, opts?: { provider?, model?, extraParams? }) => Promise,
+ * clear: () => void,
+ * }}
+ *
+ * @example
+ *
+ * {#each $chat.messages as msg}
+ * {msg.content}
+ * {/each}
+ * e.key === 'Enter' && submit()} />
+ */
+function createChatStore({
+ relayUrl = DEFAULT_RELAY_URL,
+ appId,
+ provider: defaultProvider = 'openai',
+ model: defaultModel,
+ systemPrompt,
+ extraParams = {},
+} = {}) {
+ const tokenKey = `byok_relay_token_${appId}`;
+
+ const store = writable({ messages: [], loading: false, error: null });
+
+ function _patch(patch) {
+ store.update(s => ({ ...s, ...patch }));
+ }
+
+ async function send(content, opts = {}) {
+ const provider = opts.provider || defaultProvider;
+ const model = opts.model || defaultModel;
+ const extra = { ...extraParams, ...(opts.extraParams || {}) };
+ const token = storageGet(tokenKey);
+
+ if (!token) { _patch({ error: 'Not registered — call relay.register() first' }); return; }
+
+ _patch({ error: null, loading: true });
+ store.update(s => ({
+ ...s,
+ messages: [...s.messages, { role: 'user', content }],
+ }));
+
+ try {
+ const path = (PROVIDER_PATHS[provider] || 'chat/completions').replace('{model}', model || '');
+ let body;
+
+ if (provider === 'anthropic') {
+ body = {
+ model: model || 'claude-3-haiku-20240307',
+ max_tokens: 1024,
+ messages: store.get().messages.map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content })),
+ ...(systemPrompt ? { system: systemPrompt } : {}),
+ ...extra,
+ };
+ } else {
+ const msgs = [];
+ if (systemPrompt) msgs.push({ role: 'system', content: systemPrompt });
+ msgs.push(...store.get().messages.map(m => ({ role: m.role, content: m.content })));
+ body = { model: model || 'gpt-4o-mini', messages: msgs, ...extra };
+ }
+
+ const res = await fetch(`${relayUrl}/relay/${provider}/${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify(body),
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error || `Request failed (${res.status})`);
+
+ const reply =
+ provider === 'anthropic'
+ ? data.content?.[0]?.text || ''
+ : data.choices?.[0]?.message?.content || '';
+
+ store.update(s => ({
+ ...s,
+ loading: false,
+ messages: [...s.messages, { role: 'assistant', content: reply }],
+ }));
+ } catch (err) {
+ _patch({ loading: false, error: err.message });
+ }
+ }
+
+ function clear() {
+ store.set({ messages: [], loading: false, error: null });
+ }
+
+ return { subscribe: store.subscribe, send, clear };
+}
+
+// ─── createStreamingChatStore ─────────────────────────────────────────────────
+
+/**
+ * SSE streaming chat store — live token streaming with AbortController cancel.
+ *
+ * @param {object} opts
+ * @param {string} [opts.relayUrl]
+ * @param {string} opts.appId
+ * @param {string} [opts.provider]
+ * @param {string} [opts.model]
+ * @param {string} [opts.systemPrompt]
+ * @param {object} [opts.extraParams]
+ *
+ * @returns {{
+ * subscribe: Function, // state: { messages, streamingContent, isStreaming, error }
+ * send: (content: string, opts?) => Promise,
+ * stopStreaming: () => void,
+ * clear: () => void,
+ * }}
+ *
+ * @example
+ *
+ * {#each $chat.messages as msg}
+ * {msg.content}
+ * {/each}
+ * {#if $chat.isStreaming}
+ * {$chat.streamingContent}▋
+ *
+ * {/if}
+ * e.key === 'Enter' && chat.send(input)} />
+ */
+function createStreamingChatStore({
+ relayUrl = DEFAULT_RELAY_URL,
+ appId,
+ provider: defaultProvider = 'openai',
+ model: defaultModel,
+ systemPrompt,
+ extraParams = {},
+} = {}) {
+ const tokenKey = `byok_relay_token_${appId}`;
+
+ const store = writable({
+ messages: [],
+ streamingContent: '',
+ isStreaming: false,
+ error: null,
+ });
+
+ let _controller = null;
+
+ function _patch(patch) {
+ store.update(s => ({ ...s, ...patch }));
+ }
+
+ async function send(content, opts = {}) {
+ const provider = opts.provider || defaultProvider;
+ const model = opts.model || defaultModel;
+ const extra = { ...extraParams, ...(opts.extraParams || {}) };
+ const token = storageGet(tokenKey);
+
+ if (!token) { _patch({ error: 'Not registered — call relay.register() first' }); return; }
+ if (store.get().isStreaming) stopStreaming();
+
+ _patch({ error: null, isStreaming: true, streamingContent: '' });
+ store.update(s => ({
+ ...s,
+ messages: [...s.messages, { role: 'user', content }],
+ }));
+
+ _controller = new AbortController();
+
+ try {
+ const path = (PROVIDER_PATHS[provider] || 'chat/completions').replace('{model}', model || '');
+ let body;
+
+ if (provider === 'anthropic') {
+ body = {
+ model: model || 'claude-3-haiku-20240307',
+ max_tokens: 1024,
+ stream: true,
+ messages: store.get().messages.map(m => ({ role: m.role === 'assistant' ? 'assistant' : 'user', content: m.content })),
+ ...(systemPrompt ? { system: systemPrompt } : {}),
+ ...extra,
+ };
+ } else {
+ const msgs = [];
+ if (systemPrompt) msgs.push({ role: 'system', content: systemPrompt });
+ msgs.push(...store.get().messages.map(m => ({ role: m.role, content: m.content })));
+ body = { model: model || 'gpt-4o-mini', messages: msgs, stream: true, ...extra };
+ }
+
+ const res = await fetch(`${relayUrl}/relay/${provider}/${path}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify(body),
+ signal: _controller.signal,
+ });
+
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ throw new Error(data.error || `Request failed (${res.status})`);
+ }
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder();
+ let buffer = '';
+ let full = '';
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+
+ const lines = buffer.split('\n');
+ buffer = lines.pop(); // keep incomplete line
+
+ for (const line of lines) {
+ if (!line.startsWith('data:')) continue;
+ const raw = line.slice(5).trim();
+ if (raw === '[DONE]') break;
+ try {
+ const event = JSON.parse(raw);
+ const delta = extractDelta(event, provider);
+ if (delta) {
+ full += delta;
+ _patch({ streamingContent: full });
+ }
+ } catch { /* skip malformed */ }
+ }
+ }
+
+ store.update(s => ({
+ ...s,
+ isStreaming: false,
+ streamingContent: '',
+ messages: [...s.messages, { role: 'assistant', content: full }],
+ }));
+ } catch (err) {
+ if (err.name === 'AbortError') {
+ // User stopped — commit whatever was streamed
+ const partial = store.get().streamingContent;
+ store.update(s => ({
+ ...s,
+ isStreaming: false,
+ streamingContent: '',
+ messages: partial
+ ? [...s.messages, { role: 'assistant', content: partial }]
+ : s.messages,
+ }));
+ } else {
+ _patch({ isStreaming: false, streamingContent: '', error: err.message });
+ }
+ } finally {
+ _controller = null;
+ }
+ }
+
+ function stopStreaming() {
+ if (_controller) { _controller.abort(); _controller = null; }
+ }
+
+ function clear() {
+ stopStreaming();
+ store.set({ messages: [], streamingContent: '', isStreaming: false, error: null });
+ }
+
+ return { subscribe: store.subscribe, send, stopStreaming, clear };
+}
+
+// ─── createRelayHealthStore ───────────────────────────────────────────────────
+
+/**
+ * Health polling store — tracks relay liveness and readiness.
+ *
+ * @param {object} opts
+ * @param {string} [opts.relayUrl] Relay base URL
+ * @param {number} [opts.pollIntervalMs] Polling interval ms (default: 30 000; 0 = no polling)
+ * @param {boolean}[opts.deep] If true, also pings upstream provider (/health?deep=1)
+ * @param {string} [opts.provider] Provider to deep-check (e.g. 'openai')
+ *
+ * @returns {{
+ * subscribe: Function, // state: { status, ok, uptime, warnings, error }
+ * refetch: () => Promise,
+ * destroy: () => void, // call in onDestroy to stop polling
+ * }}
+ *
+ * @example
+ *
+ *
+ * {$health.ok ? '● Live' : '● Down'}
+ *
+ */
+function createRelayHealthStore({
+ relayUrl = DEFAULT_RELAY_URL,
+ pollIntervalMs = 30_000,
+ deep = false,
+ provider,
+} = {}) {
+ const store = writable({ status: 'unknown', ok: false, uptime: null, warnings: [], error: null });
+ let _timer = null;
+
+ async function refetch() {
+ try {
+ let url = `${relayUrl}/health`;
+ if (deep) { url += '?deep=1'; if (provider) url += `&provider=${provider}`; }
+ const res = await fetch(url);
+ const data = await res.json();
+ store.set({
+ status: data.status || (res.ok ? 'ok' : 'error'),
+ ok: res.ok && data.status === 'ok',
+ uptime: data.uptime || null,
+ warnings: data.warnings || [],
+ error: null,
+ });
+ } catch (err) {
+ store.update(s => ({ ...s, status: 'unreachable', ok: false, error: err.message }));
+ }
+ }
+
+ refetch();
+
+ if (pollIntervalMs > 0) {
+ _timer = setInterval(refetch, pollIntervalMs);
+ }
+
+ function destroy() {
+ if (_timer) { clearInterval(_timer); _timer = null; }
+ }
+
+ return { subscribe: store.subscribe, refetch, destroy };
+}
+
+// ─── Exports ──────────────────────────────────────────────────────────────────
+
+module.exports = {
+ createByokRelayStore,
+ createChatStore,
+ createStreamingChatStore,
+ createRelayHealthStore,
+};
diff --git a/packages/svelte/test/stores.test.js b/packages/svelte/test/stores.test.js
new file mode 100644
index 0000000..cb1a92f
--- /dev/null
+++ b/packages/svelte/test/stores.test.js
@@ -0,0 +1,261 @@
+/**
+ * @byok-relay/svelte — smoke tests
+ *
+ * Run with: node test/stores.test.js
+ *
+ * Tests run in plain Node (no Svelte build step) because all stores export
+ * pure JS objects with a Svelte-compatible { subscribe, set, update } interface.
+ */
+
+'use strict';
+
+// Top-level await requires wrapping in an async runner
+async function runTests() {
+
+const {
+ createByokRelayStore,
+ createChatStore,
+ createStreamingChatStore,
+ createRelayHealthStore,
+} = require('../src/index.js');
+
+let passed = 0;
+let failed = 0;
+
+function assert(condition, label) {
+ if (condition) {
+ console.log(` ✅ ${label}`);
+ passed++;
+ } else {
+ console.error(` ❌ FAIL: ${label}`);
+ failed++;
+ }
+}
+
+function assertThrows(fn, label) {
+ try {
+ fn();
+ console.error(` ❌ FAIL (no throw): ${label}`);
+ failed++;
+ } catch {
+ console.log(` ✅ ${label}`);
+ passed++;
+ }
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+/** Read current store value synchronously. */
+function get(store) {
+ let v;
+ const unsub = store.subscribe(val => { v = val; });
+ unsub();
+ return v;
+}
+
+// ─── createByokRelayStore ──────────────────────────────────────────────────────
+
+console.log('\n── createByokRelayStore ─────────────────────────────────────────');
+
+{
+ const relay = createByokRelayStore({ appId: 'test-svelte' });
+
+ assert(typeof relay.subscribe === 'function', 'has subscribe');
+ assert(typeof relay.register === 'function', 'has register()');
+ assert(typeof relay.storeKey === 'function', 'has storeKey()');
+ assert(typeof relay.deleteKey === 'function', 'has deleteKey()');
+ assert(typeof relay.listProviders === 'function', 'has listProviders()');
+ assert(typeof relay.logout === 'function', 'has logout()');
+
+ const state = get(relay);
+ assert(typeof state === 'object', 'initial state is object');
+ assert('token' in state, 'state has token');
+ assert('isRegistered' in state, 'state has isRegistered');
+ assert('error' in state, 'state has error');
+ assert(state.error === null, 'initial error is null');
+ assert(typeof state.isRegistered === 'boolean', 'isRegistered is boolean');
+}
+
+{
+ // logout clears state
+ const relay = createByokRelayStore({ appId: 'test-logout-svelte' });
+ relay.logout();
+ const state = get(relay);
+ assert(state.token === null, 'logout clears token');
+ assert(state.isRegistered === false,'logout clears isRegistered');
+}
+
+{
+ // subscribe fires immediately with current value
+ const relay = createByokRelayStore({ appId: 'test-sub-svelte' });
+ let fired = 0;
+ const unsub = relay.subscribe(() => fired++);
+ assert(fired === 1, 'subscribe fires immediately');
+ unsub();
+}
+
+{
+ // storeKey rejects if not registered
+ const relay = createByokRelayStore({ appId: 'test-store-key-svelte' });
+ relay.logout(); // ensure no token
+ let threw = false;
+ relay.storeKey('openai', 'sk-test').catch(() => { threw = true; });
+ // give microtask a tick
+ await new Promise(r => setTimeout(r, 10));
+ assert(threw, 'storeKey throws if not registered');
+}
+
+// ─── createChatStore ───────────────────────────────────────────────────────────
+
+console.log('\n── createChatStore ──────────────────────────────────────────────');
+
+{
+ const chat = createChatStore({ appId: 'test-chat-svelte', provider: 'openai' });
+
+ assert(typeof chat.subscribe === 'function', 'has subscribe');
+ assert(typeof chat.send === 'function', 'has send()');
+ assert(typeof chat.clear === 'function', 'has clear()');
+
+ const state = get(chat);
+ assert(Array.isArray(state.messages), 'messages is array');
+ assert(state.messages.length === 0, 'messages starts empty');
+ assert(state.loading === false, 'loading starts false');
+ assert(state.error === null, 'error starts null');
+}
+
+{
+ // clear resets state
+ const chat = createChatStore({ appId: 'test-clear-svelte', provider: 'openai' });
+ // Manually inject a message by reaching into store update (simulate prior conversation)
+ chat.clear();
+ const state = get(chat);
+ assert(state.messages.length === 0, 'clear empties messages');
+}
+
+{
+ // send without token sets error (no token in localStorage for test-notoken-svelte)
+ const chat = createChatStore({ appId: 'test-notoken-svelte', provider: 'openai' });
+ await chat.send('hello');
+ const state = get(chat);
+ assert(typeof state.error === 'string', 'send without token sets error string');
+ assert(state.loading === false, 'loading false after error');
+}
+
+// ─── createStreamingChatStore ─────────────────────────────────────────────────
+
+console.log('\n── createStreamingChatStore ─────────────────────────────────────');
+
+{
+ const chat = createStreamingChatStore({ appId: 'test-stream-svelte', provider: 'openai' });
+
+ assert(typeof chat.subscribe === 'function', 'has subscribe');
+ assert(typeof chat.send === 'function', 'has send()');
+ assert(typeof chat.stopStreaming === 'function', 'has stopStreaming()');
+ assert(typeof chat.clear === 'function', 'has clear()');
+
+ const state = get(chat);
+ assert(Array.isArray(state.messages), 'messages is array');
+ assert(state.messages.length === 0, 'messages starts empty');
+ assert(state.isStreaming === false, 'isStreaming starts false');
+ assert(state.streamingContent === '', 'streamingContent starts empty string');
+ assert(state.error === null, 'error starts null');
+}
+
+{
+ // stopStreaming is a no-op when not streaming
+ const chat = createStreamingChatStore({ appId: 'test-stop-svelte' });
+ chat.stopStreaming(); // should not throw
+ assert(true, 'stopStreaming no-op when idle');
+}
+
+{
+ // clear resets all state
+ const chat = createStreamingChatStore({ appId: 'test-stream-clear-svelte' });
+ chat.clear();
+ const s = get(chat);
+ assert(s.messages.length === 0, 'clear empties messages');
+ assert(s.isStreaming === false, 'clear sets isStreaming false');
+ assert(s.streamingContent === '', 'clear clears streamingContent');
+}
+
+{
+ // send without token sets error
+ const chat = createStreamingChatStore({ appId: 'test-stream-notoken-svelte', provider: 'anthropic' });
+ await chat.send('hi');
+ const s = get(chat);
+ assert(typeof s.error === 'string', 'send without token sets error');
+ assert(s.isStreaming === false, 'isStreaming false after error');
+}
+
+// ─── createRelayHealthStore ───────────────────────────────────────────────────
+
+console.log('\n── createRelayHealthStore ───────────────────────────────────────');
+
+{
+ // No polling
+ const health = createRelayHealthStore({ relayUrl: 'http://localhost:19999', pollIntervalMs: 0 });
+
+ assert(typeof health.subscribe === 'function', 'has subscribe');
+ assert(typeof health.refetch === 'function', 'has refetch()');
+ assert(typeof health.destroy === 'function', 'has destroy()');
+
+ // Initial state
+ const init = get(health);
+ assert('status' in init, 'state has status');
+ assert('ok' in init, 'state has ok');
+ assert('warnings' in init, 'state has warnings');
+ assert('error' in init, 'state has error');
+
+ // Wait for the initial fetch to fail (unreachable host)
+ await new Promise(r => setTimeout(r, 200));
+ const after = get(health);
+ assert(after.ok === false, 'ok=false on unreachable host');
+ assert(after.status === 'unreachable', 'status=unreachable on network error');
+
+ health.destroy(); // stop polling (no-op since pollIntervalMs=0)
+}
+
+{
+ // destroy stops polling timer
+ const health = createRelayHealthStore({ relayUrl: 'http://localhost:19999', pollIntervalMs: 5_000 });
+ health.destroy();
+ assert(true, 'destroy() does not throw');
+}
+
+// ─── Svelte store contract ────────────────────────────────────────────────────
+
+console.log('\n── Svelte store contract ────────────────────────────────────────');
+
+{
+ // All stores expose { subscribe } = valid Svelte store
+ const relay = createByokRelayStore({ appId: 'contract-svelte' });
+ const chat = createChatStore({ appId: 'contract-svelte' });
+ const stream = createStreamingChatStore({ appId: 'contract-svelte' });
+ const health = createRelayHealthStore({ pollIntervalMs: 0 });
+
+ for (const [name, store] of [
+ ['createByokRelayStore', relay],
+ ['createChatStore', chat],
+ ['createStreamingChatStore', stream],
+ ['createRelayHealthStore', health],
+ ]) {
+ assert(typeof store.subscribe === 'function', `${name}: subscribe is function`);
+ // Svelte contract: subscribe must call the callback immediately and return unsubscribe fn
+ let called = 0;
+ const unsub = store.subscribe(() => called++);
+ assert(called === 1, `${name}: subscribe fires immediately`);
+ assert(typeof unsub === 'function', `${name}: subscribe returns unsubscribe function`);
+ unsub();
+ health.destroy();
+ }
+}
+
+// ─── Summary ─────────────────────────────────────────────────────────────────
+
+console.log(`\n${'─'.repeat(56)}`);
+console.log(`@byok-relay/svelte smoke tests: ${passed} passed, ${failed} failed`);
+if (failed > 0) process.exit(1);
+
+} // end runTests
+
+runTests().catch(err => { console.error(err); process.exit(1); });