Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 3 additions & 3 deletions .claude/docs/tech/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,14 +151,14 @@ export function ClientComponent({ data }: Props) {
POSTGRES_URL=postgresql://...
AUTH_SECRET=<generate-with-openssl>
NEXTAUTH_URL=http://localhost:3000
OPENAI_API_KEY=sk-...
BYOK_ENCRYPTION_KEY=<generate-with-openssl> # Encrypts per-user LLM API keys
```

### Optional Variables
```env
GOOGLE_CLIENT_ID=... # Google OAuth
GITHUB_CLIENT_ID=... # GitHub OAuth
DEEPSEEK_API_KEY=... # Alternative LLM
OPENAI_API_KEY=sk-... # Platform key: demo mode + embeddings only
DEMO_MODE_ENABLED=true # Demo features
```

Expand Down Expand Up @@ -188,7 +188,7 @@ pnpm lint # Lint all code
### File Locations
- **Auth**: `apps/web/app/(auth)/auth.ts`, `apps/web/lib/getAuthUser.ts`
- **Database**: `packages/database/src/schema.ts`, `packages/database/src/queries.ts`
- **AI**: `apps/web/lib/ai/extract-achievements.ts`, `apps/web/lib/ai/llm-router.ts`
- **AI**: `apps/web/lib/ai/extract-achievements.ts`, `apps/web/lib/ai/resolve-model.ts`, `packages/ai/`
- **CLI**: `packages/cli/src/index.ts`, `packages/cli/src/commands/`
- **API**: `apps/web/app/api/*/route.ts`

Expand Down
128 changes: 99 additions & 29 deletions .claude/docs/tech/ai-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,37 +6,100 @@ BragDoc uses the Vercel AI SDK v5 to integrate multiple LLM providers for achiev

## Supported Providers

### Web Application
- OpenAI (GPT-4, GPT-4o, GPT-3.5-turbo)
Both the web application and the CLI support the same six providers via the
shared `@bragdoc/ai` package:

- OpenAI (GPT-4o, GPT-4.1, etc.)
- Anthropic Claude (Claude Sonnet, Opus)
- Google Gemini (Gemini-1.5-pro, Gemini-2.0-flash)
- DeepSeek (deepseek-chat)
- Anthropic Claude (planned)

### CLI Tool
- OpenAI
- Google Gemini
- DeepSeek
- Anthropic Claude (Claude-3.5-Sonnet, Opus)
- Ollama (local models: llama3.2, qwen2.5-coder, etc.)
- Ollama (local models: llama3.2, qwen2.5-coder, etc. — no API key)
- OpenAI-compatible APIs (LM Studio, LocalAI, etc.)

## LLM Router
## BYOK Architecture (Bring Your Own Key)

**File:** `apps/web/lib/ai/llm-router.ts`
BragDoc is free; all web AI features run on each user's own LLM API key.
There is no platform-key fallback for regular users — the old module-level
model singletons (`routerModel`, `documentWritingModel`,
`extractAchievementsModel`, `chatModel`) and the user-less `getLLM(taskType)`
helper were removed entirely.

Intelligently selects the appropriate LLM provider based on:
- Task type (extraction, generation, chat)
- Provider availability
### Shared Provider Package — `@bragdoc/ai`

```typescript
export async function getLLM(
user: User,
taskType: 'extraction' | 'generation' | 'chat'
): Promise<LanguageModel> {
// Selection logic based on user.level and taskType
// Returns configured model from @ai-sdk/*
}
```
**Location:** `packages/ai/`

Extracted from the CLI and now used by both the CLI and the web app:

- `LLMProvider` union + per-provider config interfaces (`LLMConfig`)
- `DEFAULT_MODELS`, `PROVIDER_OPTIONS` (display names + API-key signup URLs)
- `createLLMFromConfig(config)` — provider factory (no env mutation; Google
uses `createGoogleGenerativeAI({ apiKey })`)
- `verifyLLMConfig(config)` — instantiates the model and runs a tiny
`generateText` probe (15s timeout); returns `{ ok } | { ok: false, error }`.
Never throws. Used by the settings "Verify & Save" flow.
- Zod-free public surface; built to `dist/` with declarations so the
published CLI can consume compiled output.

### Model Resolution — `resolveModelForUser`

**File:** `apps/web/lib/ai/resolve-model.ts`

Every server-side AI call obtains its model through
`resolveModelForUser(user, task)` (task: `'chat' | 'extraction' | 'generation'`):

1. **Demo users** (`user.isDemo === true`) → platform `OPENAI_API_KEY` with
the historical task→model mapping (extraction: gpt-4o-mini, generation:
gpt-4.1-mini, chat: gpt-4o). Demo keeps working with zero setup.
2. **Otherwise** → load the user's default `user_llm_config` row, decrypt
the stored API key, and build the model via `createLLMFromConfig`. The
user's single chosen model serves all tasks in v1.
3. **No config** → throw the typed `NoLLMConfigError`.

Routes catch `NoLLMConfigError` and return HTTP 409
`{ error: 'no_llm_configured' }` (via `noLLMConfigResponse()`). AI feature
surfaces (chat, document generation, standups, performance review,
workstreams) detect this code and show a "Connect your AI provider" CTA
linking to Settings.

### Key Storage — `user_llm_config` Table

One row per (user, provider); at most one `isDefault` row per user. Stores
`encryptedApiKey` + `iv` (base64, nullable for keyless providers like
Ollama), `keyHint` (last 4 chars for display), `model`, `baseURL`, and
`lastVerifiedAt`. See `database.md` for the full schema.

### Encryption — `apps/web/lib/crypto/llm-keys.ts`

- WebCrypto (`crypto.subtle`) AES-256-GCM with a random 12-byte IV per
encryption (Cloudflare Workers compatible).
- The AES key is derived via HKDF-SHA256 from the dedicated
`BYOK_ENCRYPTION_KEY` secret (deliberately NOT `BETTER_AUTH_SECRET`, so it
can rotate independently).
- Decryption happens strictly server-side inside route handlers / model
resolution. Plaintext keys never appear in API responses, logs, or client
components — `GET /api/user/llm-config` returns only masked metadata
(provider, model, keyHint, isDefault, lastVerifiedAt).

### Verify-on-Save

`PUT /api/user/llm-config` runs `verifyLLMConfig` with the raw key before
encrypting and storing it; verification failures are rejected with the
provider's error message. `lastVerifiedAt` records the last successful probe.

### Embeddings Exception (v1 Decision)

Workstreams embeddings stay on the platform `OPENAI_API_KEY`. Embeddings are
OpenAI-only (`text-embedding-3-small`, 1536-d pgvector column); Anthropic has
no embeddings API, and per-user embedding providers would mix vector
dimensions and invalidate stored vectors. Cost is negligible (~$0.02/1M
tokens), and Workstreams keeps working for Anthropic/Ollama users. Demo mode
is the only other platform-key consumer.

### CLI Relationship

The CLI keeps its own local BYOK config (`bragdoc llm set`, stored in
`~/.bragdoc/config.yml`) for local extraction — it does not use the key
stored in the web app. Both now share the `@bragdoc/ai` provider factory.

## Achievement Extraction

Expand All @@ -46,10 +109,10 @@ Extracts achievements from Git commits using streaming:

```typescript
import { streamObject } from 'ai';
import { getLLM } from './llm-router';
import { resolveModelForUser } from './resolve-model';

export async function* extractAchievements(commits: Commit[], user: User) {
const llm = await getLLM(user, 'extraction');
const llm = await resolveModelForUser(user, 'extraction');

const { partialObjectStream } = streamObject({
model: llm,
Expand Down Expand Up @@ -178,6 +241,11 @@ bragdoc llm set ollama llama3.2 # Set to local Ollama model

## Provider Configuration

In the web app, provider instantiation is handled by
`createLLMFromConfig` in `@bragdoc/ai` with the user's decrypted key — the
env-var examples below illustrate the underlying AI SDK calls (the CLI also
falls back to these env vars when no key is in its config).

### OpenAI
```typescript
import { createOpenAI } from '@ai-sdk/openai';
Expand Down Expand Up @@ -455,8 +523,9 @@ export const updatePerformanceReviewDocument = ({ user, dataStream }: UpdatePerf

// 3. Stream updated content
let draftContent = '';
const model = await resolveModelForUser(user, 'generation');
const { fullStream } = streamText({
model: documentWritingModel,
model,
system: updateDocumentPrompt(document.content, 'text'),
experimental_transform: smoothStream({ chunking: 'word' }),
prompt: description,
Expand Down Expand Up @@ -492,7 +561,8 @@ The tool is registered in the chat route using `createUIMessageStream`:
const stream = createUIMessageStream({
execute: ({ writer: dataStream }) => {
const result = streamText({
model: routerModel,
model, // from resolveModelForUser(user, 'chat'); NoLLMConfigError → 409

system: systemPrompt,
messages: convertToModelMessages(uiMessages),
tools: {
Expand Down Expand Up @@ -552,5 +622,5 @@ const { messages, sendMessage, status } = useChat({

---

**Last Updated:** 2026-01-13
**Last Updated:** 2026-07-06
**Vercel AI SDK:** v5.0.0
2 changes: 1 addition & 1 deletion .claude/docs/tech/api-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -785,7 +785,7 @@ The endpoint registers the `updatePerformanceReviewDocument` tool which enables
const stream = createUIMessageStream({
execute: ({ writer: dataStream }) => {
const result = streamText({
model: routerModel,
model, // from resolveModelForUser(user, 'chat')
system: enhancedSystemPrompt,
messages: convertToModelMessages(uiMessages),
tools: {
Expand Down
8 changes: 4 additions & 4 deletions .claude/docs/tech/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -718,10 +718,10 @@ NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com
MAILGUN_API_KEY=xxx
MAILGUN_DOMAIN=mg.your-domain.com

# AI/LLM
OPENAI_API_KEY=sk-xxx
DEEPSEEK_API_KEY=xxx # Optional
GOOGLE_GENERATIVE_AI_API_KEY=xxx # Optional
# AI/LLM (BYOK)
BYOK_ENCRYPTION_KEY=xxx # Encrypts per-user LLM API keys at rest (openssl rand -base64 32)
OPENAI_API_KEY=sk-xxx # Platform key: demo mode + Workstreams embeddings only;
# all other AI features use each user's own key (Settings → AI Provider)
```

### Optional Variables
Expand Down
53 changes: 53 additions & 0 deletions .claude/docs/tech/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,59 @@ export const workstreamMetadata = pgTable('WorkstreamMetadata', {

---

### UserLLMConfig Table (BYOK)
**Purpose**: Per-user LLM provider configuration — Bring Your Own Key. Web AI features run on the key stored here (see `ai-integration.md` for the resolution flow).

```typescript
export const llmProviderEnum = pgEnum('llm_provider', [
'openai',
'anthropic',
'google',
'deepseek',
'ollama',
'openai_compatible',
]);

export const userLLMConfig = pgTable(
'user_llm_config',
{
id: uuid('id').primaryKey().notNull().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
provider: llmProviderEnum('provider').notNull(),
encryptedApiKey: text('encrypted_api_key'), // AES-256-GCM ciphertext, base64 (null for keyless providers)
iv: text('iv'), // 12-byte IV, base64
keyHint: varchar('key_hint', { length: 8 }), // Last 4 chars for display; never return the key itself
model: varchar('model', { length: 256 }).notNull(),
baseURL: varchar('base_url', { length: 512 }), // Ollama / OpenAI-compatible endpoints
isDefault: boolean('is_default').notNull().default(false),
lastVerifiedAt: timestamp('last_verified_at'), // Last successful verify-on-save probe
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => ({
userIdIdx: index('user_llm_config_user_id_idx').on(table.userId),
// One config per (user, provider)
userProviderUnique: uniqueIndex('user_llm_config_user_provider_unique').on(
table.userId,
table.provider,
),
// At most one default config per user (partial unique index)
userDefaultUnique: uniqueIndex('user_llm_config_user_default_unique')
.on(table.userId)
.where(sql`${table.isDefault} = true`),
}),
);
```

**Key Points:**
- **Encryption**: `encryptedApiKey`/`iv` hold AES-256-GCM output from `apps/web/lib/crypto/llm-keys.ts` (key derived via HKDF-SHA256 from `BYOK_ENCRYPTION_KEY`). Both are nullable because keyless providers (Ollama) have no ciphertext.
- **Default row**: exactly one row per user may have `isDefault = true`, enforced by a partial unique index; `getDefaultLLMConfig(userId)` reads it during model resolution.
- **Account deletion**: `deleteAccountData()` hard-deletes rows explicitly — the user row is anonymized rather than deleted, so the FK cascade never fires on its own.

---

### Session Table
```typescript
export const session = pgTable('Session', {
Expand Down
7 changes: 7 additions & 0 deletions .claude/docs/tech/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ if (process.env.NODE_ENV === 'development') {
AUTH_SECRET
NEXTAUTH_URL
OPENAI_API_KEY
BYOK_ENCRYPTION_KEY
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
GITHUB_CLIENT_ID
Expand Down Expand Up @@ -282,7 +283,13 @@ NEXT_PUBLIC_POSTHOG_KEY=phc_xxx # Same for both apps (cross-domain tracking)
NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com

# AI/LLM
# Platform key — only used for demo mode and Workstreams embeddings.
# Regular users' AI features run on their own keys (BYOK, added in Settings).
OPENAI_API_KEY=sk-...

# BYOK — encrypts user LLM API keys at rest (AES-256-GCM, key via HKDF-SHA256)
# Generate with: openssl rand -base64 32
BYOK_ENCRYPTION_KEY=...
```

### Optional
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,8 @@ pnpm changeset version # Update versions
- `POSTGRES_URL` - Database connection string
- `BETTER_AUTH_SECRET` - Better Auth secret key (or `AUTH_SECRET` for backward compatibility)
- `BETTER_AUTH_URL` - Application URL (or `NEXTAUTH_URL` for backward compatibility)
- `OPENAI_API_KEY` - OpenAI API key (for LLM)
- `BYOK_ENCRYPTION_KEY` - Encrypts per-user LLM API keys at rest (required for web AI features; generate with `openssl rand -base64 32`)
- `OPENAI_API_KEY` - Platform OpenAI key, used only for demo mode and Workstreams embeddings (all other AI features run on each user's own BYOK key from Settings → AI Provider)

**Optional:**
- `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` - Google OAuth
Expand Down
Loading
Loading