diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..865444f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,132 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Philosophy + +BotMaker packages OpenClaw into a turnkey bot platform. We are responsible +for producing **useful, usable, and working configurations** for all users. +"OpenClaw-side issue" is not an excuse — if the generated config is broken, +that's our bug. Every bot created through the wizard must work out of the box. + +Users download this project from GitHub/GHCR. They may run: +- 100% cloud APIs (Anthropic, OpenAI, Google, etc.) with zero Ollama +- 100% local Ollama with zero cloud APIs +- A mix of both + +The UI wizard, template generation, and proxy configuration must produce +valid, working configs for **every** combination. + +## Build & Dev Commands + +### Backend (root) +```bash +npm run build # Compile TypeScript +npm run dev # Hot-reload dev server (tsx watch) +npm run start # Run compiled server +npm run test # Vitest unit tests +npm run lint # ESLint +npx vitest run src/bots/store.test.ts # Single test file +``` + +### Dashboard (`dashboard/`) +```bash +npm run dev # Vite dev server (localhost:5173) +npm run build # TypeScript check + Vite build +npm run test # Vitest + React Testing Library +``` + +### Proxy (`proxy/`) +```bash +npm run dev # Hot-reload (tsx watch) +npm run build # TypeScript compile +npm run test # Vitest +``` + +### Full build +```bash +npm run build:all # Backend + dashboard +``` + +### Docker +```bash +docker compose up -d # Start botmaker + keyring-proxy +docker compose --profile build build botenv # Build bot environment image +``` + +Test framework is **Vitest** (not Jest) across all three modules. Tests use `*.test.ts` pattern. + +## Architecture + +Three independent TypeScript services sharing one repo: + +``` +src/ → Backend: Fastify API (port 7100), bot lifecycle, Docker orchestration +proxy/src/ → Keyring-proxy: credential vault (admin:9100, data:9101), request forwarding +dashboard/src/ → React + Vite frontend: wizard, dashboard, secrets management +``` + +All three have their own `package.json`, `tsconfig.json`, and `vitest.config.ts`. + +### Request Flow +``` +User → Dashboard UI → Backend API (POST /api/bots) + ↓ + Creates: openclaw.json (templates.ts) + Creates: Docker container (DockerService.ts) + Registers: bot with keyring-proxy (proxy/client.ts) + ↓ +Bot container → keyring-proxy:9101/v1/{provider}/... → upstream API + (injects real API key at network edge) +``` + +### Zero-Trust Credential Model +Bots never hold real API keys. They get a proxy token that keyring-proxy +validates, then the proxy injects the real API key when forwarding upstream. +Provider names get a `-proxy` suffix (e.g., `openai-proxy`) to avoid +collisions with OpenClaw's built-in provider defaults. + +### Key Files +- `src/server.ts` — All API routes (bot CRUD, auth, stats, admin) +- `src/bots/templates.ts` — Generates openclaw.json from wizard input +- `src/bots/store.ts` — Bot DB CRUD (better-sqlite3, no ORM) +- `src/services/DockerService.ts` — Container lifecycle +- `proxy/src/types.ts` — All 15+ LLM vendor configs + `initOllamaVendor()` +- `proxy/src/services/upstream.ts` — Transparent request forwarding (any path) +- `proxy/src/routes/proxy.ts` — Auth validation, key selection, forwarding +- `dashboard/src/wizard/` — Multi-step bot creation wizard +- `dashboard/src/config/providers/` — Provider definitions (22 providers) + +### Database +Direct SQL via better-sqlite3 (no ORM). Two separate SQLite databases: +- Backend: `${DATA_DIR}/botmaker.db` — `bots` table +- Proxy: `${DB_PATH}/proxy.db` — `provider_keys`, `bots`, `usage_logs` (AES-256 encrypted keys) + +### Provider API Type Mapping +`templates.ts:getApiTypeForProvider()` maps provider IDs to OpenClaw API types: +- `anthropic` → `anthropic-messages` +- `google` → `google-gemini` +- `openai` → `openai-responses` +- Everything else (ollama, groq, deepseek, mistral, etc.) → `openai-completions` + +### Ollama Integration +- Optional: enabled by `OLLAMA_UPSTREAM` env var on keyring-proxy +- Uses `noAuth: true` + `forceNonStreaming: true` (OpenClaw can't parse streaming tool-call deltas) +- Bots address it identically to cloud: `http://keyring-proxy:9101/v1/ollama` +- Context window set via `OLLAMA_CONTEXT_LENGTH` env var on Ollama container + +## Known Issue: memorySearch + +OpenClaw's memorySearch auto-discovery looks for providers named exactly +`"openai"` or `"gemini"`. Our `-proxy` suffix means auto-discovery always +fails. The template generator must explicitly produce a `memorySearch` +section in openclaw.json pointing at the correct keyring-proxy embedding +endpoint, or explicitly disable it for providers without embedding support. + +~13/22 providers support OpenAI-compatible `/embeddings`; the rest +(anthropic, groq, cerebras, perplexity, moonshot) need `enabled: false`. + +## CI + +GitHub Actions (`.github/workflows/ci.yml`): Node 20+22 matrix, lints and +tests all three modules, builds Docker image, pushes to GHCR on main. diff --git a/docker-compose.yml b/docker-compose.yml index 3d9106f..ee613b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,7 +60,7 @@ services: - MASTER_KEY_FILE=/secrets/master_key - ADMIN_TOKEN_FILE=/secrets/admin_token # Optional: enables Ollama vendor (local LLM support) - # - OLLAMA_UPSTREAM=http://host.docker.internal:11434 + - OLLAMA_UPSTREAM=http://host.docker.internal:11434 volumes: - proxy-data:/data - ./secrets:/secrets:ro diff --git a/src/bots/templates.test.ts b/src/bots/templates.test.ts index a13ee95..4e99a29 100644 --- a/src/bots/templates.test.ts +++ b/src/bots/templates.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, rmSync, existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { randomUUID } from 'node:crypto'; -import { createBotWorkspace, getBotWorkspacePath, deleteBotWorkspace, getApiTypeForProvider, type BotWorkspaceConfig } from './templates.js'; +import { createBotWorkspace, getBotWorkspacePath, deleteBotWorkspace, getApiTypeForProvider, getMemorySearchConfig, type BotWorkspaceConfig } from './templates.js'; describe('templates', () => { let testDir: string; @@ -60,6 +60,37 @@ describe('templates', () => { }); }); + describe('getMemorySearchConfig', () => { + const proxy = { baseUrl: 'http://proxy:9101/v1/openai', token: 'tok-123' }; + + it.each([ + ['openai', 'text-embedding-3-small'], + ['mistral', 'mistral-embed'], + ['ollama', 'nomic-embed-text'], + ['deepseek', 'text-embedding-3-small'], + ])('%s with proxy returns embedding model %s', (provider, model) => { + expect(getMemorySearchConfig(provider, proxy)).toEqual({ + provider: 'openai', + model, + remote: { baseUrl: proxy.baseUrl, apiKey: proxy.token }, + }); + }); + + it.each([ + 'anthropic', 'google', 'groq', 'cerebras', 'perplexity', 'moonshot', 'ovhcloud', + ])('%s returns disabled', (provider) => { + expect(getMemorySearchConfig(provider, proxy)).toEqual({ enabled: false }); + }); + + it('unknown provider returns disabled', () => { + expect(getMemorySearchConfig('nonexistent', proxy)).toEqual({ enabled: false }); + }); + + it('no proxy returns disabled even for supported provider', () => { + expect(getMemorySearchConfig('openai')).toEqual({ enabled: false }); + }); + }); + describe('getBotWorkspacePath', () => { it('should return correct path', () => { const result = getBotWorkspacePath('/data', 'my-bot'); @@ -111,7 +142,9 @@ describe('templates', () => { controlUi: { allowInsecureAuth: true }, }); expect((openclawConfig.channels as Record).telegram).toEqual({ enabled: true }); - expect(((openclawConfig.agents as Record).defaults as Record).model).toEqual({ primary: 'openai/gpt-4' }); + const defaults = (openclawConfig.agents as Record).defaults as Record; + expect(defaults.model).toEqual({ primary: 'openai/gpt-4' }); + expect(defaults.memorySearch).toEqual({ enabled: false }); expect(openclawConfig.models).toBeUndefined(); }); @@ -137,6 +170,16 @@ describe('templates', () => { }, }, }); + + const defaults = (openclawConfig.agents as Record).defaults as Record; + expect(defaults.memorySearch).toEqual({ + provider: 'openai', + model: 'text-embedding-3-small', + remote: { + baseUrl: 'http://proxy:9101/v1', + apiKey: 'proxy-token-123', + }, + }); }); it('should create sessions and sandbox directories', () => { @@ -202,6 +245,9 @@ describe('templates', () => { }, }, }); + + const defaults = (openclawConfig.agents as Record).defaults as Record; + expect(defaults.memorySearch).toEqual({ enabled: false }); }); it('should use openai-completions API type for venice with proxy', () => { diff --git a/src/bots/templates.ts b/src/bots/templates.ts index 921eec3..2166148 100644 --- a/src/bots/templates.ts +++ b/src/bots/templates.ts @@ -87,6 +87,68 @@ export function getApiTypeForProvider(provider: string): string { } } +/** + * Map provider to default embedding model. + * null = provider has no OpenAI-compatible /embeddings endpoint. + */ +const OPENAI_EMBED = 'text-embedding-3-small'; +const NOMIC_EMBED = 'nomic-embed-text'; + +export const EMBEDDING_MODELS: Record = { + openai: OPENAI_EMBED, + mistral: 'mistral-embed', + deepseek: OPENAI_EMBED, + ollama: NOMIC_EMBED, + fireworks: NOMIC_EMBED, + togetherai: 'togethercomputer/m2-bert-80M-8k-retrieval', + deepinfra: 'BAAI/bge-large-en-v1.5', + nvidia: 'NV-Embed-QA', + grok: OPENAI_EMBED, + nebius: OPENAI_EMBED, + scaleway: OPENAI_EMBED, + huggingface: 'sentence-transformers/all-MiniLM-L6-v2', + minimax: 'embo-01', + venice: OPENAI_EMBED, + openrouter: `openai/${OPENAI_EMBED}`, + // No embedding support + anthropic: null, + google: null, + groq: null, + cerebras: null, + perplexity: null, + moonshot: null, + ovhcloud: null, +}; + +export type MemorySearchConfig = + | { enabled: false } + | { provider: 'openai'; model: string; remote: { baseUrl: string; apiKey: string } }; + +/** + * Build memorySearch config for openclaw.json. + * Returns embedding endpoint config for providers with /embeddings support, + * or { enabled: false } for providers without it. + */ +export function getMemorySearchConfig( + provider: string, + proxy?: ProxyConfig, +): MemorySearchConfig { + const embeddingModel = EMBEDDING_MODELS[provider]; + + if (!embeddingModel || !proxy) { + return { enabled: false }; + } + + return { + provider: 'openai', + model: embeddingModel, + remote: { + baseUrl: proxy.baseUrl, + apiKey: proxy.token, + }, + }; +} + /** * Generate openclaw.json configuration. * Follows OpenClaw's expected config structure for gateway mode. @@ -142,6 +204,7 @@ function generateOpenclawConfig(config: BotWorkspaceConfig): object { primary: modelSpec, }, workspace: '/app/botdata/workspace', + memorySearch: getMemorySearchConfig(config.aiProvider, config.proxy), }, }, ...(modelsConfig && { models: modelsConfig }),