Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
27811c0
feat(byok): add @tanstack/ai-byok bring-your-own-key toolkit
tombeckenham Jul 7, 2026
a46ed4a
ci: apply automated fixes
autofix-ci[bot] Jul 7, 2026
73b3756
feat(byok): passkey-encrypted storage; drop plaintext localStorage
tombeckenham Jul 7, 2026
9d5096f
feat(byok): wire BYOK into ts-react-chat example; add passkey rpId op…
tombeckenham Jul 7, 2026
73d1f15
test(byok): add E2E coverage for the BYOK relay flow
tombeckenham Jul 7, 2026
2f736b6
ci: apply automated fixes
autofix-ci[bot] Jul 7, 2026
c96d26e
feat(example): integrate BYOK into the main chat with a key icon + en…
tombeckenham Jul 7, 2026
257f64b
feat(byok): surface saved keys as "locked" after refresh (peek); exam…
tombeckenham Jul 7, 2026
b727833
feat(byok): add withByok connection helper β€” prompt to add/unlock key…
tombeckenham Jul 7, 2026
2757e2e
refactor(byok): drop vendor branding from wire and storage identifiers
tombeckenham Jul 8, 2026
979283b
feat(byok): add byokFetcher for the fetcher transport
tombeckenham Jul 8, 2026
5fff8c4
ci: apply automated fixes
autofix-ci[bot] Jul 8, 2026
2eaae9f
feat(ai-byok): add OpenRouter PKCE, docs, and unified passkey storage
tombeckenham Jul 16, 2026
ac92507
ci: apply automated fixes
autofix-ci[bot] Jul 16, 2026
47f2eaa
refactor(byok): address code review β€” server helpers, shared guards, …
tombeckenham Jul 16, 2026
eb08b30
ci: apply automated fixes
autofix-ci[bot] Jul 16, 2026
f16a3cf
docs(byok): fix kiira type errors in BYOK guide and API reference
tombeckenham Jul 16, 2026
018902c
refactor(byok): resolve keys then create* adapters, validate model wi…
tombeckenham Aug 13, 2026
d2fa160
fix(byok): address CodeRabbit review β€” PKCE exchange, placeholder, docs
tombeckenham Aug 13, 2026
ab56c9f
fix(byok): hydrate locked keyring before persist so saves cannot wipe…
tombeckenham Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/byok-package.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/ai-byok': minor
---

Add `@tanstack/ai-byok`: a bring-your-own-key toolkit for TanStack AI. Keys live client-side and travel to the relay in a per-provider header (`x-byok-<provider>`), never the request body or message history.

- **Client** (`@tanstack/ai-byok`): `byokHeaders`, `withByok`/`byokFetch` (attach BYOK headers to a connection and detect the relay's `byokMissing` 401 so the UI can prompt for or unlock the missing key), `byokFetcher` (the same for the `fetcher` transport β€” `useChat`/`useGeneration` β€” covering both a fetch call and a TanStack Start server function via call-site headers), a typed provider registry, pluggable storage (session-only memory by default, opt-in passkey-encrypted persistence β€” WebAuthn PRF β†’ HKDF β†’ AES-256-GCM in IndexedDB, no plaintext option), and `validateKey`. Storage may expose `peek()` to report `provider β†’ last-4` presence without decrypting.
- **React** (`@tanstack/ai-byok/react`): `<ByokProvider storage={…}>`, `useByok()` (with `locked`/`unlock` for encrypted storage), and a drop-in `<ByokKeyManager>` settings UI that only ever shows the last 4 characters of a saved key. After a refresh, saved keys from encrypted storage surface as a `locked` status (with last-4) before the biometric unlock.
- **Server** (`@tanstack/ai-byok/server`): `getByokKey` (header-only, never logged), `byokMissing` (typed error response), and `scrubSecrets`/`maskKey` for keeping key material out of logs and errors. Stateless pass-through β€” no persistence, no central endpoint.
337 changes: 337 additions & 0 deletions docs/api/ai-byok.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,337 @@
---
title: "@tanstack/ai-byok"
slug: /api/ai-byok
order: 9
description: "API reference for @tanstack/ai-byok β€” bring-your-own-key client keyring, per-request headers, React bindings, and stateless server helpers."
keywords:
- tanstack ai
- "@tanstack/ai-byok"
- byok
- api key
- getByokKey
- withByok
- byokFetcher
- api reference
---

Bring-your-own-key toolkit for TanStack AI. See the [BYOK guide](../advanced/byok) for architecture, security model, and end-to-end setup.

## Installation

```bash
npm install @tanstack/ai-byok
```

Subpath exports:

```typescript
// Stateless server helpers (no React dependency)
import { getByokKey, byokMissing } from "@tanstack/ai-byok/server";

// React bindings (requires react peer)
import { ByokProvider, useByok } from "@tanstack/ai-byok/react";

// OpenRouter OAuth PKCE (optional vendor add-on)
import { useOpenRouterPkce } from "@tanstack/ai-byok/openrouter/react";
```

## `@tanstack/ai-byok` (client)

Framework-agnostic client toolkit. No React or server dependencies.

### `byokHeaders(keys)`

Turns a keyring into per-provider request headers. Skips empty or absent keys.

```typescript
import { byokHeaders } from "@tanstack/ai-byok";

const headers = byokHeaders({ openai: "sk-live", anthropic: "" });
// β†’ { "x-byok-openai": "sk-live" }
```

### `withByok(getKeys, options?)`

Builds BYOK connection options for fetch-based [connection adapters](../chat/connection-adapters). Returns a **function** that produces fresh options on every request.

```tsx
import { useRef } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { withByok } from "@tanstack/ai-byok";
import { openKeyDialog } from "./byok-ui";
import { customFetch } from "./fetch";

function Chat() {
const keysRef = useRef({ openai: "sk-live" });
const buildOptions = withByok(() => keysRef.current, {
onMissingKey: (provider) => openKeyDialog(provider),
headers: { "x-custom": "value" },
fetchClient: customFetch,
});

useChat({
connection: fetchServerSentEvents("/api/chat", buildOptions),
});

return null;
}
```

**`WithByokOptions`**

| Field | Type | Description |
| --- | --- | --- |
| `onMissingKey?` | `(provider: ProviderId) => void` | Called when the relay returns a `byokMissing` 401 |
| `headers?` | `Record<string, string>` | Extra headers merged under BYOK headers |
| `fetchClient?` | `typeof fetch` | Underlying fetch (defaults to global `fetch`) |

### `buildByokRequestContext(getKeys, options?, signal?)`

Shared header + fetch wiring used by `withByok` and `byokFetcher`. Returns `{ headers, fetch, signal? }`.

### `byokFetch(onMissingKey, fetchImpl?)`

Wraps `fetch` so a `byokMissing` 401 invokes `onMissingKey` with the provider id. The response is passed through unchanged.

### `byokFetcher(getKeys, handler, options?)`

The `fetcher` transport counterpart to `withByok`. Wraps a fetcher body so it receives BYOK `headers`, a missing-key-aware `fetch`, and the transport `signal`, read fresh on every call.

```tsx
import { useRef } from "react";
import { useGenerateAudio } from "@tanstack/ai-react";
import { byokFetcher } from "@tanstack/ai-byok";
import { openKeyDialog } from "./byok-ui";

function AudioPage() {
const keysRef = useRef({ elevenlabs: "xi-key" });

useGenerateAudio({
fetcher: byokFetcher(
() => keysRef.current,
(input, { headers, fetch, signal }) =>
fetch("/api/generate", {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify(input),
signal,
}),
{ onMissingKey: (provider) => openKeyDialog(provider) },
),
});

return null;
}
```

**`ByokFetcherContext`**

| Field | Type | Description |
| --- | --- | --- |
| `headers` | `Record<string, string>` | Per-provider BYOK headers for this request |
| `fetch` | `typeof fetch` | Missing-key-aware fetch (identical to global when `onMissingKey` is unset) |
| `signal?` | `AbortSignal` | Abort signal forwarded from `stop()`, when provided |

### `isByokMissingBody(value)`

Type guard for a `byokMissing` response body parsed from JSON.

### Storage

#### `defaultByokStorage(options?)`

Recommended: passkey-encrypted storage when supported, otherwise session memory. All keys (pasted and OpenRouter PKCE) use the same tier.

#### `memoryStorage()`

Session-only β€” keys vanish on refresh, nothing persisted. Default when `<ByokProvider>` has no `storage` prop.

#### `passkeyStorage(options?)`

Passkey-encrypted persistence (WebAuthn PRF β†’ HKDF β†’ AES-256-GCM in IndexedDB). Unlockable β€” requires `unlock()` or a save before keys are usable after refresh.

**`PasskeyStorageOptions`**

| Field | Type | Default | Description |
| --- | --- | --- | --- |
| `rpName?` | `string` | `"BYOK"` | Relying-party name in the passkey prompt |
| `userName?` | `string` | `"byok-keyring"` | Username label on the created passkey |
| `rpId?` | `string` | current origin | Parent domain for cross-subdomain sharing |
| `dbName?` | `string` | `"byok"` | IndexedDB database name |

#### `isPasskeyStorageSupported()`

Returns whether WebAuthn is available. PRF support is confirmed only during registration β€” catch errors from `passkeyStorage()` and fall back to `memoryStorage()`.

### OpenRouter PKCE (`@tanstack/ai-byok/openrouter`)

| Export | Description |
| --- | --- |
| `generateCodeVerifier()` | Random URL-safe PKCE verifier |
| `createS256CodeChallenge(verifier)` | S256 code challenge (base64url SHA-256) |
| `buildOpenRouterAuthUrl(options)` | OpenRouter `/auth` redirect URL |
| `startOpenRouterPkceLogin(options)` | Store pending state and redirect |
| `exchangeOpenRouterCode(options)` | POST code β†’ API key |
| `completeOpenRouterPkceFromUrl(options?)` | Read `?code=`, exchange, clean up |
| `defaultOpenRouterCallbackUrl()` | `origin + pathname` default |
| `storeOpenRouterPkcePending` / `load` / `clear` | Session-scoped PKCE state |

### `useOpenRouterPkce(options?)` (`@tanstack/ai-byok/openrouter/react`)

React hook (requires `<ByokProvider>`). Auto-completes when the URL contains `?code=` from OpenRouter (unless `autoComplete: false`).

| Option | Default | Description |
| --- | --- | --- |
| `callbackUrl?` | `origin + pathname` | OpenRouter redirect target |
| `autoComplete?` | `true` | Exchange code on mount |
| `useS256?` | `true` | S256 PKCE challenge |

Returns `{ login, completing, error, callbackUrl }`.

### `validateKey(provider, key)`

Pings the provider's validation endpoint. Returns `'valid' | 'invalid' | 'unsupported'`. Throws on network/CORS failure or unexpected HTTP status.

### Provider registry

| Export | Description |
| --- | --- |
| `BYOK_PROVIDERS` | Static metadata map (`id`, `label`, optional `validate` config) |
| `PROVIDER_IDS` | Runtime array of all provider ids |
| `BYOK_HEADER_PREFIX` | `"x-byok-"` |
| `byokHeaderName(provider)` | Full header name for a provider |
| `isProviderId(value)` | Type guard for `ProviderId` |

### Types

| Type | Description |
| --- | --- |
| `Keyring` | `Partial<Record<ProviderId, string>>` β€” in-memory key map |
| `ProviderId` | Union of registered provider ids |
| `KeyringStorage` | Pluggable persistence interface (`load`, `save`, `clear`, optional `peek`, `unlockable`) |
| `ValidationStatus` | `'valid' \| 'invalid' \| 'unsupported'` |
| `ByokMissingBody` | Typed JSON body from `byokMissing` |

## `@tanstack/ai-byok/server`

Stateless server helpers. No persistence, no logging of key values.

### `getByokKey(request, provider)`

Reads a provider's BYOK key from the incoming request header. Returns the key string or `null` when absent.

Accepts any object with a `Headers`-like `.get()` β€” works across Fetch-API runtimes (Workers, Deno, Bun, Node/undici).

```typescript
import { getByokKey } from "@tanstack/ai-byok/server";

export async function POST(request: Request) {
const apiKey = getByokKey(request, "openai");
// ...
}
```

### `byokMissing(provider, init?)`

Returns a typed JSON 401 telling the client which provider key is missing. Carries no key material.

```typescript
import { byokMissing, getByokKey } from "@tanstack/ai-byok/server";

export async function POST(request: Request) {
const apiKey = getByokKey(request, "anthropic");
if (!apiKey) return byokMissing("anthropic");
// ...
}
```

### `getByokOrEnvKey(request, provider, envVarNames)`

Header key if present, otherwise the first non-empty named env var. Returns `null` when neither is set β€” callers should `return byokMissing(provider)` and pass the key to `createOpenaiChat(model, apiKey)` (or the matching `create*` factory).

### `scrubSecrets(input, secrets)`

Replaces every occurrence of each secret in `input` with its masked form. Use before logging or returning strings that may have interpolated a key.

### `maskKey(key)` / `lastFour(key)`

- `maskKey` β€” display-safe form (`…last4`, or `…` when the key is 4 characters or shorter).
- `lastFour` β€” raw trailing 4 characters. Not display-safe: a short key is returned in full. Use `maskKey` in UI and logs.

## `@tanstack/ai-byok/react`

React bindings for the client keyring.

### `<ByokProvider storage={...}>`

Provides the keyring context. `storage` is chosen once at mount and cannot change.

```tsx
import { ByokProvider, memoryStorage } from "@tanstack/ai-byok/react";

function Root({ children }: { children: React.ReactNode }) {
return (
<ByokProvider storage={memoryStorage()}>{children}</ByokProvider>
);
}
```

### `useByok()`

Access the keyring and controls. Must be called under `<ByokProvider>` β€” throws otherwise.

```tsx
import { useByok } from "@tanstack/ai-byok/react";

function KeySettings() {
const {
keys, // live keyring β€” pass to byokHeaders / withByok
setKey, // (provider, key) => Promise<void>
clearKey, // (provider) => Promise<void>
clearAll, // () => Promise<void>
validateKey, // (provider, key?) => Promise<KeyStatus>
status, // per-provider KeyStatus map
storage, // configured KeyringStorage
locked, // true when unlockable storage may hold encrypted keys
unlock, // () => Promise<void> β€” decrypt / load
hasKey, // true only for a decrypted key this session β€” not for locked keys
storageError, // mount/load failure from the configured storage, if any
} = useByok();

return null;
}
```

**`KeyStatus` union**

| `state` | Meaning |
| --- | --- |
| `'empty'` | No key stored |
| `'set'` | Key present and usable; `masked` shows last 4 |
| `'locked'` | Saved in unlockable storage but not decrypted this session |
| `'validating'` | Validation in flight |
| `'valid'` / `'invalid'` / `'unsupported'` | Validation outcome |
| `'error'` | Validation or persist failed; includes `message` |

### `<ByokKeyManager providers={...} />`

Drop-in settings UI for entering, validating, and clearing keys. Only ever shows the last four characters of a saved key. Accepts optional `envStatus`, `highlightProvider`, `openRouter`, and `variant` (`'light' | 'dark'`).

### `<ByokKeyDialog open onOpenChange={...} />`

Modal wrapper around `<ByokKeyManager>` with a trigger button. Supports custom `trigger`, `overlayClassName`, and `panelClassName` for app styling.

## Header convention

Every present provider key is sent as:

```http
x-byok-<provider>: <api-key>
```

Keys are **never** placed in the request body, `forwardedProps`, or message history.

## Related

- [Bring Your Own Key (BYOK)](../advanced/byok) β€” full guide
- [Connection Adapters](../chat/connection-adapters) β€” `withByok` integration
2 changes: 2 additions & 0 deletions docs/chat/connection-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ const { messages } = useChat({
});
```

> **Tip:** For [bring-your-own-key (BYOK)](../advanced/byok) flows, use `withByok` from `@tanstack/ai-byok` as the connection options factory. It merges `x-byok-<provider>` headers on every request and detects the relay's `byokMissing` 401 so the UI can prompt for (or unlock) the missing key.

**Static body.** Anything in `options.body` is merged into the AG-UI `forwardedProps` payload sent to your server. Per-message data passed to `sendMessage` wins over this:

```typescript
Expand Down
5 changes: 5 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,11 @@
"to": "api/ai-angular",
"addedAt": "2026-06-15",
"updatedAt": "2026-08-18"
},
{
"label": "@tanstack/ai-byok",
"to": "api/ai-byok",
"addedAt": "2026-08-13"
}
]
},
Expand Down
9 changes: 9 additions & 0 deletions docs/getting-started/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,15 @@ Solid hooks for TanStack AI:
- Tool approval flow support
- Type-safe message handling with `InferChatMessages`

### `@tanstack/ai-byok`
Bring-your-own-key toolkit for apps where users supply their own provider API keys:
- Client-side keyring with session-only or passkey-encrypted storage
- Per-request `x-byok-<provider>` headers (never the message body)
- Stateless server helpers (`getByokKey`, `byokMissing`) that never persist or log keys
- React bindings (`<ByokProvider>`, `useByok`, `<ByokKeyManager>`)

See the [BYOK guide](../advanced/byok).

## Adapters

With the help of adapters, TanStack AI can connect to various LLM providers. Available adapters include:
Expand Down
1 change: 1 addition & 0 deletions examples/ts-react-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"@tanstack/ai-anthropic": "workspace:*",
"@tanstack/ai-bedrock": "workspace:*",
"@tanstack/ai-byteplus": "workspace:*",
"@tanstack/ai-byok": "workspace:*",
"@tanstack/ai-claude-code": "workspace:*",
"@tanstack/ai-client": "workspace:*",
"@tanstack/ai-code-mode": "workspace:*",
Expand Down
Loading
Loading