From 3f352cfccec746e0d083d1ad23d5a2e1111d9150 Mon Sep 17 00:00:00 2001
From: Luis Novo
Date: Tue, 10 Feb 2026 08:30:22 -0300
Subject: [PATCH] feat: credential-based API key management (#477) (#540)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: replace provider config with credential-based system (#477)
Introduce a new credential management system replacing the old
ProviderConfig singleton and standalone Models page. Each credential
stores encrypted API keys and provider-specific configuration with
full CRUD support via a unified settings UI.
Backend:
- Add Credential domain model with encrypted API key storage
- Add credentials API router (CRUD, discovery, registration, testing)
- Add encryption utilities for secure key storage
- Add key_provider for DB-first env-var fallback provisioning
- Add connection tester and model discovery services
- Integrate ModelManager with credential-based config
- Add provider name normalization for Esperanto compatibility
- Add database migrations 11-12 for credential schema
Frontend:
- Rewrite settings/api-keys page with credential management UI
- Add model discovery dialog with search and custom model support
- Add compact default model assignments (primary/advanced layout)
- Add inline model testing and credential connection testing
- Add env-var migration banner
- Update navigation to unified settings page
- Remove standalone models page and old settings components
i18n:
- Update all 7 locale files with credential and model management keys
Closes #477
Co-Authored-By: JFMD
Co-Authored-By: OraCatQAQ <570768706@qq.com>
* fix: address PR #540 review comments
- Fix docs referencing removed Models page
- Fix error-handler returning raw messages instead of i18n keys
- Fix auth.py misleading docstring and missing no-password guard
- Fix connection_tester using wrong env var for openai_compatible
- Add provision_provider_keys before model discovery/sync
- Update CLAUDE.md to reflect credential-based system
- Fix missing closing brace in api-keys page useEffect
* fix: add logging to credential migration and surface errors in UI
- Add comprehensive logging to migrate-from-env and
migrate-from-provider-config endpoints (start, per-provider
progress, success/failure with stack traces, final summary)
- Fix frontend migration hooks ignoring errors array from response
- Show error toast when migration fails instead of "nothing to migrate"
- Invalidate status/envStatus queries after migration so banner updates
* docs: update CLAUDE.md files for credential system
Replace stale ProviderConfig and /api-keys/ references across 8 CLAUDE.md
files to reflect the new Credential-based system from PR #540.
* docs: update user documentation for credential-based system
Replace env var API key instructions with Settings UI credential
workflow across all user-facing documentation. The new flow is:
set OPEN_NOTEBOOK_ENCRYPTION_KEY → start services → add credential
in Settings UI → test → discover models → register.
- Rewrite ai-providers.md, api-configuration.md, environment-reference.md
- Update all quick-start guides and installation docs
- Update ollama.md, openai-compatible.md, local-tts/stt networking sections
- Update reverse-proxy.md, development-setup.md, security.md
- Fix broken links to non-existent docs/deployment/ paths
- Add credentials endpoints to api-reference.md
- Move all API key env vars to deprecated/legacy sections
* chore: bump version to 1.7.0-rc1
Release candidate for credential-based provider management system.
* fix: initialize provider before try block in test_credential
Prevents UnboundLocalError when Credential.get() throws (e.g.,
invalid credential_id) before provider is assigned.
* fix: reorder down migration to drop index before table
Removes duplicate REMOVE FIELD statement and reorders so the index
is dropped before the table, preventing rollback failures.
* refactor: simplify encryption key to always derive via SHA-256
Remove the dual code path in _ensure_fernet_key() that detected native
Fernet keys. Since the credential system is new, always deriving via
SHA-256 removes unnecessary complexity. Also removes the generate_key()
function and Fernet.generate_key() references from docs.
* fix: correct mock patch targets in embedding tests and URL validation
Fix embedding tests patching wrong module path for model_manager
(was targeting open_notebook.utils.embedding.model_manager but it's
imported locally from open_notebook.ai.models). Also fix URL validation
to allow unresolvable hostnames since they may be valid in the
deployment environment (e.g., Azure endpoints, internal DNS).
* feat: add global setup banner for encryption and migration status
Show a persistent banner in AppShell when encryption key is missing
(red) or env var API keys can be migrated (amber), so users see
these prompts on every page instead of only on Settings > API Keys.
Includes a docs link for the encryption banner and i18n support
across all 7 locales.
* docs: several improvements to docker-compose e env examples
* Update README.md
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
* docs: fix env var format in README and update model setup instructions
Align the encryption key snippet in README Step 2 with the list
format used in the compose file. Replace deprecated "Settings →
Models" instructions with credential-based Discover Models flow.
* fix: address credential system review issues
- Fix SSRF bypass via IPv4-mapped IPv6 addresses (::ffff:169.254.x.x)
- Fix TTS connection test missing config parameter
- Add Azure-specific model discovery using api-key auth header
- Add Vertex static model list for credential-based discovery
- Fix PROVIDER_DISCOVERY_FUNCTIONS incorrect azure/vertex mapping
- Extract business logic to api/credentials_service.py (service layer)
- Move credential Pydantic schemas to api/models.py
- Update tests to use new service imports and ValueError assertions
* fix: sanitize error responses and migrate key_provider to Credential
- Replace raw exception messages in all credential router 500 responses
with generic error strings (internal details logged server-side only)
- Refactor key_provider.py to use Credential.get_by_provider() instead
of deprecated ProviderConfig.get_instance()
- Remove unused functions (get_provider_configs, get_default_api_key,
get_provider_config) that were dead code
---------
Co-authored-by: JFMD
Co-authored-by: OraCatQAQ <570768706@qq.com>
---
.env.example | 305 +---
.gitignore | 1 +
CHANGELOG.md | 36 +
CLAUDE.md | 5 +-
README.md | 101 +-
api/CLAUDE.md | 112 ++
api/auth.py | 16 +-
api/credentials_service.py | 883 +++++++++++
api/main.py | 17 +-
api/models.py | 230 ++-
api/routers/auth.py | 9 +-
api/routers/credentials.py | 386 +++++
api/routers/models.py | 543 ++++++-
docker-compose.full.yml | 28 -
docker-compose.yml | 36 +
docs/0-START-HERE/quick-start-cloud.md | 69 +-
docs/0-START-HERE/quick-start-local.md | 64 +-
docs/0-START-HERE/quick-start-openai.md | 37 +-
docs/1-INSTALLATION/docker-compose.md | 153 +-
docs/1-INSTALLATION/from-source.md | 15 +-
docs/1-INSTALLATION/index.md | 6 +-
docs/1-INSTALLATION/single-container.md | 31 +-
docs/3-USER-GUIDE/api-configuration.md | 390 +++++
docs/3-USER-GUIDE/index.md | 15 +
docs/4-AI-PROVIDERS/index.md | 14 +-
docs/5-CONFIGURATION/advanced.md | 61 +-
docs/5-CONFIGURATION/ai-providers.md | 277 ++--
docs/5-CONFIGURATION/environment-reference.md | 260 +--
docs/5-CONFIGURATION/index.md | 114 +-
docs/5-CONFIGURATION/local-stt.md | 46 +-
docs/5-CONFIGURATION/local-tts.md | 38 +-
docs/5-CONFIGURATION/ollama.md | 80 +-
docs/5-CONFIGURATION/openai-compatible.md | 85 +-
docs/5-CONFIGURATION/reverse-proxy.md | 8 +-
docs/5-CONFIGURATION/security.md | 65 +-
docs/6-TROUBLESHOOTING/ai-chat-issues.md | 101 +-
docs/6-TROUBLESHOOTING/connection-issues.md | 6 +-
docs/6-TROUBLESHOOTING/quick-fixes.md | 26 +-
docs/7-DEVELOPMENT/api-reference.md | 10 +
docs/7-DEVELOPMENT/development-setup.md | 29 +-
docs/SECURITY_REVIEW.md | 96 ++
examples/README.md | 163 ++
.../docker-compose-dev.yml | 0
examples/docker-compose-full-local.yml | 197 +++
examples/docker-compose-ollama.yml | 63 +
.../docker-compose-single.yml | 0
examples/docker-compose-speaches.yml | 125 ++
.../models/components/AddModelForm.tsx | 151 --
.../components/DefaultModelsSection.tsx | 280 ----
.../models/components/ModelTypeSection.tsx | 213 ---
.../models/components/ProviderStatus.tsx | 131 --
frontend/src/app/(dashboard)/models/page.tsx | 102 --
.../(dashboard)/settings/api-keys/page.tsx | 1395 +++++++++++++++++
.../src/app/(dashboard)/settings/page.tsx | 1 +
.../src/components/common/CommandPalette.tsx | 2 +-
frontend/src/components/layout/AppShell.tsx | 2 +
frontend/src/components/layout/AppSidebar.tsx | 2 +-
.../src/components/layout/SetupBanner.tsx | 84 +
.../podcasts/GeneratePodcastDialog.tsx | 39 +-
.../settings}/EmbeddingModelChangeDialog.tsx | 2 +-
.../components/settings/MigrationBanner.tsx | 53 +
.../settings/ModelTestResultDialog.tsx | 63 +
frontend/src/components/settings/index.ts | 3 +
frontend/src/lib/api/CLAUDE.md | 92 ++
frontend/src/lib/api/credentials.ts | 239 +++
frontend/src/lib/api/models.ts | 72 +-
frontend/src/lib/hooks/CLAUDE.md | 125 ++
frontend/src/lib/hooks/use-credentials.ts | 388 +++++
frontend/src/lib/hooks/use-models.ts | 86 +-
frontend/src/lib/locales/en-US/index.ts | 111 +-
frontend/src/lib/locales/it-IT/index.ts | 105 +-
frontend/src/lib/locales/ja-JP/index.ts | 111 +-
frontend/src/lib/locales/pt-BR/index.ts | 111 +-
frontend/src/lib/locales/ru-RU/index.ts | 105 +-
frontend/src/lib/locales/zh-CN/index.ts | 109 ++
frontend/src/lib/locales/zh-TW/index.ts | 109 ++
frontend/src/lib/types/models.ts | 41 +
open_notebook/CLAUDE.md | 393 +++--
open_notebook/ai/CLAUDE.md | 226 ++-
open_notebook/ai/connection_tester.py | 438 ++++++
open_notebook/ai/key_provider.py | 297 ++++
open_notebook/ai/model_discovery.py | 756 +++++++++
open_notebook/ai/models.py | 77 +-
open_notebook/database/CLAUDE.md | 4 +-
open_notebook/database/async_migrate.py | 12 +
.../database/migrations/11.surrealql | 10 +
.../database/migrations/11_down.surrealql | 4 +
.../database/migrations/12.surrealql | 29 +
.../database/migrations/12_down.surrealql | 5 +
open_notebook/domain/CLAUDE.md | 17 +
open_notebook/domain/__init__.py | 7 +
open_notebook/domain/credential.py | 199 +++
open_notebook/domain/provider_config.py | 444 ++++++
open_notebook/utils/CLAUDE.md | 34 +
open_notebook/utils/__init__.py | 8 +
open_notebook/utils/embedding.py | 12 +-
open_notebook/utils/encryption.py | 198 +++
pyproject.toml | 3 +-
tests/conftest.py | 12 +
tests/test_chunking.py | 1 -
tests/test_embedding.py | 11 +-
tests/test_url_validation.py | 130 ++
uv.lock | 344 +---
103 files changed, 10681 insertions(+), 2669 deletions(-)
create mode 100644 api/credentials_service.py
create mode 100644 api/routers/credentials.py
delete mode 100644 docker-compose.full.yml
create mode 100644 docker-compose.yml
create mode 100644 docs/3-USER-GUIDE/api-configuration.md
create mode 100644 docs/SECURITY_REVIEW.md
create mode 100644 examples/README.md
rename docker-compose.dev.yml => examples/docker-compose-dev.yml (100%)
create mode 100644 examples/docker-compose-full-local.yml
create mode 100644 examples/docker-compose-ollama.yml
rename docker-compose.single.yml => examples/docker-compose-single.yml (100%)
create mode 100644 examples/docker-compose-speaches.yml
delete mode 100644 frontend/src/app/(dashboard)/models/components/AddModelForm.tsx
delete mode 100644 frontend/src/app/(dashboard)/models/components/DefaultModelsSection.tsx
delete mode 100644 frontend/src/app/(dashboard)/models/components/ModelTypeSection.tsx
delete mode 100644 frontend/src/app/(dashboard)/models/components/ProviderStatus.tsx
delete mode 100644 frontend/src/app/(dashboard)/models/page.tsx
create mode 100644 frontend/src/app/(dashboard)/settings/api-keys/page.tsx
create mode 100644 frontend/src/components/layout/SetupBanner.tsx
rename frontend/src/{app/(dashboard)/models/components => components/settings}/EmbeddingModelChangeDialog.tsx (97%)
create mode 100644 frontend/src/components/settings/MigrationBanner.tsx
create mode 100644 frontend/src/components/settings/ModelTestResultDialog.tsx
create mode 100644 frontend/src/components/settings/index.ts
create mode 100644 frontend/src/lib/api/credentials.ts
create mode 100644 frontend/src/lib/hooks/use-credentials.ts
create mode 100644 open_notebook/ai/connection_tester.py
create mode 100644 open_notebook/ai/key_provider.py
create mode 100644 open_notebook/ai/model_discovery.py
create mode 100644 open_notebook/database/migrations/11.surrealql
create mode 100644 open_notebook/database/migrations/11_down.surrealql
create mode 100644 open_notebook/database/migrations/12.surrealql
create mode 100644 open_notebook/database/migrations/12_down.surrealql
create mode 100644 open_notebook/domain/credential.py
create mode 100644 open_notebook/domain/provider_config.py
create mode 100644 open_notebook/utils/encryption.py
create mode 100644 tests/test_url_validation.py
diff --git a/.env.example b/.env.example
index 857ca91..0b12ada 100644
--- a/.env.example
+++ b/.env.example
@@ -1,276 +1,59 @@
+# Open Notebook Configuration
+# Copy this file to .env and customize as needed
-# API CONFIGURATION
-# URL where the API can be accessed by the browser
-# This setting allows the frontend to connect to the API at runtime (no rebuild needed!)
-#
-# IMPORTANT: Do NOT include /api at the end - it will be added automatically!
-#
-# Common scenarios:
-# - Docker on localhost: http://localhost:5055 (default, works for most cases)
-# - Docker on LAN/remote server: http://192.168.1.100:5055 or http://your-server-ip:5055
-# - Behind reverse proxy with custom domain: https://your-domain.com
-# - Behind reverse proxy with subdomain: https://api.your-domain.com
-#
-# Examples for reverse proxy users:
-# - API_URL=https://notebook.example.com (frontend will call https://notebook.example.com/api/*)
-# - API_URL=https://api.example.com (frontend will call https://api.example.com/api/*)
-#
-# Note: If not set, the system will auto-detect based on the incoming request.
-# Only set this if you need to override the auto-detection (e.g., reverse proxy scenarios).
-API_URL=http://localhost:5055
+# =============================================================================
+# REQUIRED
+# =============================================================================
-# INTERNAL API URL (Server-Side)
-# URL where Next.js server-side should proxy API requests (via rewrites)
-# This is DIFFERENT from API_URL which is used by the browser client
-#
-# INTERNAL_API_URL is used by Next.js rewrites to forward /api/* requests to the FastAPI backend
-# API_URL is used by the browser to know where to make API calls
-#
-# Default: http://localhost:5055 (single-container deployment - both services on same host)
-# Override for multi-container: INTERNAL_API_URL=http://api-service:5055
-#
-# Common scenarios:
-# - Single container (default): Don't set - defaults to http://localhost:5055
-# - Multi-container Docker Compose: INTERNAL_API_URL=http://api:5055 (use service name)
-# - Kubernetes/advanced networking: INTERNAL_API_URL=http://api-service.namespace.svc.cluster.local:5055
-#
-# Why two variables?
-# - API_URL: External/public URL that browsers use (can be https://your-domain.com)
-# - INTERNAL_API_URL: Internal container networking URL (usually http://localhost:5055 or service name)
-#
-# INTERNAL_API_URL=http://localhost:5055
+# Encryption key for storing API credentials securely in the database
+# Change this to any secret string (minimum 16 characters recommended)
+OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
-# API CLIENT TIMEOUT (in seconds)
-# Controls how long the frontend/Streamlit UI waits for API responses
-# Increase this if you're using slow AI providers or hardware (Ollama on CPU, remote LM Studio, etc.)
-# Default: 300 seconds (5 minutes) - sufficient for most transformation/insight operations
-#
-# Common scenarios:
-# - Fast cloud APIs (OpenAI, Anthropic): 300 seconds is more than enough
-# - Local Ollama on GPU: 300 seconds should work fine
-# - Local Ollama on CPU: Consider 600 seconds (10 minutes) or more
-# - Remote LM Studio over slow network: Consider 900 seconds (15 minutes)
-# - Very large documents: May need 900+ seconds
-#
-# API_CLIENT_TIMEOUT=300
+# =============================================================================
+# DATABASE (Default values work with docker-compose.yml)
+# =============================================================================
-# ESPERANTO LLM TIMEOUT (in seconds)
-# Controls the timeout for AI model API calls at the Esperanto library level
-# This is separate from API_CLIENT_TIMEOUT and applies to the actual LLM provider requests
-# Only increase this if you're experiencing timeouts during model inference itself
-# Default: 60 seconds (built into Esperanto)
-#
-# Important: This should generally be LOWER than API_CLIENT_TIMEOUT to allow proper error handling
-#
-# Common scenarios:
-# - Fast cloud APIs (OpenAI, Anthropic, Groq): 60 seconds is sufficient
-# - Local Ollama with small models: 120-180 seconds may help
-# - Local Ollama with large models on CPU: 300+ seconds
-# - Remote or self-hosted LLMs: 180-300 seconds depending on hardware
-#
-# Note: If transformations complete but you see timeout errors, increase API_CLIENT_TIMEOUT first.
-# Only increase ESPERANTO_LLM_TIMEOUT if the model itself is timing out during inference.
-#
-# ESPERANTO_LLM_TIMEOUT=60
+SURREAL_URL=ws://surrealdb:8000/rpc
+SURREAL_USER=root
+SURREAL_PASSWORD=root
+SURREAL_NAMESPACE=open_notebook
+SURREAL_DATABASE=open_notebook
-# SSL VERIFICATION CONFIGURATION
-# Configure SSL certificate verification for local AI providers (Ollama, LM Studio, etc.)
-# behind reverse proxies with self-signed certificates
-#
-# Option 1: Custom CA Bundle (recommended for self-signed certs)
-# Point to your CA certificate file to verify SSL while using custom certificates
-# ESPERANTO_SSL_CA_BUNDLE=/path/to/your/ca-bundle.pem
-#
-# Option 2: Disable SSL Verification (development only)
-# WARNING: Disabling SSL verification exposes you to man-in-the-middle attacks
-# Only use in trusted development/testing environments
-# ESPERANTO_SSL_VERIFY=false
+# =============================================================================
+# OPTIONAL: AI Provider API Keys
+# =============================================================================
+# You can configure these via the UI (Settings → API Keys) or set them here
+# UI configuration is recommended for better security and flexibility
-# SECURITY
-# Set this to protect your Open Notebook instance with a password (for public hosting)
-# OPEN_NOTEBOOK_PASSWORD=
+# OpenAI
+# OPENAI_API_KEY=sk-...
-# HTTP/HTTPS PROXY
-# Route all external HTTP requests through a proxy server
-# Useful for corporate/firewalled environments
-#
-# The underlying libraries (esperanto, content-core, podcast-creator) automatically
-# detect proxy settings from these standard environment variables.
-#
-# Affects:
-# - AI provider API calls (OpenAI, Anthropic, Google, etc.)
-# - Content extraction from URLs (web scraping, YouTube)
-# - Podcast generation (LLM and TTS calls)
-#
-# Examples:
-# HTTP_PROXY=http://proxy.corp.com:8080
-# HTTPS_PROXY=http://proxy.corp.com:8080
-# NO_PROXY=localhost,127.0.0.1,.local
+# Anthropic
+# ANTHROPIC_API_KEY=sk-ant-...
-# OPENAI
-# OPENAI_API_KEY=
+# Google AI
+# GOOGLE_API_KEY=...
+# Groq
+# GROQ_API_KEY=gsk_...
-# ANTHROPIC
-# ANTHROPIC_API_KEY=
+# =============================================================================
+# OPTIONAL: Advanced Configuration
+# =============================================================================
-# GEMINI
-# this is the best model for long context and podcast generation
-# GOOGLE_API_KEY=
-# GEMINI_API_BASE_URL= # Optional: Override default endpoint (for Vertex AI, proxies, etc.)
+# External API URL (for webhooks, callbacks, etc.)
+# API_URL=http://localhost:5055
-# VERTEXAI
-# VERTEX_PROJECT=my-google-cloud-project-name
-# GOOGLE_APPLICATION_CREDENTIALS=./google-credentials.json
-# VERTEX_LOCATION=us-east5
+# Ollama endpoint (if running locally)
+# OLLAMA_BASE_URL=http://ollama:11434
-# MISTRAL
-# MISTRAL_API_KEY=
+# Content processing
+# CHUNK_SIZE=1500
+# CHUNK_OVERLAP=150
-# DEEPSEEK
-# DEEPSEEK_API_KEY=
+# Security
+# BASIC_AUTH_USERNAME=admin
+# BASIC_AUTH_PASSWORD=secret
-# OLLAMA
-# OLLAMA_API_BASE="http://10.20.30.20:11434"
-
-# OPEN ROUTER
-# OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
-# OPENROUTER_API_KEY=
-
-# GROQ
-# GROQ_API_KEY=
-
-# XAI
-# XAI_API_KEY=
-
-# ELEVENLABS
-# Used only by the podcast feature
-# ELEVENLABS_API_KEY=
-
-# TTS BATCH SIZE
-# Controls concurrent TTS requests for podcast generation (default: 5)
-# Lower values reduce provider load but increase generation time
-# Recommended: OpenAI=5, ElevenLabs=2, Google=4, Custom=1
-# TTS_BATCH_SIZE=2
-
-# VOYAGE AI
-# VOYAGE_API_KEY=
-
-# OPENAI COMPATIBLE ENDPOINTS
-# Generic configuration (applies to all modalities: language, embedding, STT, TTS)
-# OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
-# OPENAI_COMPATIBLE_API_KEY=
-
-# Mode-specific configuration (overrides generic if set)
-# Use these when you want different endpoints for different capabilities
-# OPENAI_COMPATIBLE_BASE_URL_LLM=http://localhost:1234/v1
-# OPENAI_COMPATIBLE_API_KEY_LLM=
-# OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:8080/v1
-# OPENAI_COMPATIBLE_API_KEY_EMBEDDING=
-# OPENAI_COMPATIBLE_BASE_URL_STT=http://localhost:9000/v1
-# OPENAI_COMPATIBLE_API_KEY_STT=
-# OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:9000/v1
-# OPENAI_COMPATIBLE_API_KEY_TTS=
-
-# AZURE OPENAI
-# Generic configuration (applies to all modalities: language, embedding, STT, TTS)
-# AZURE_OPENAI_API_KEY=
-# AZURE_OPENAI_ENDPOINT=
-# AZURE_OPENAI_API_VERSION=2024-12-01-preview
-
-# Mode-specific configuration (overrides generic if set)
-# Use these when you want different deployments for different AI capabilities
-# AZURE_OPENAI_API_KEY_LLM=
-# AZURE_OPENAI_ENDPOINT_LLM=
-# AZURE_OPENAI_API_VERSION_LLM=
-
-# AZURE_OPENAI_API_KEY_EMBEDDING=
-# AZURE_OPENAI_ENDPOINT_EMBEDDING=
-# AZURE_OPENAI_API_VERSION_EMBEDDING=
-
-# AZURE_OPENAI_API_KEY_STT=
-# AZURE_OPENAI_ENDPOINT_STT=
-# AZURE_OPENAI_API_VERSION_STT=
-
-# AZURE_OPENAI_API_KEY_TTS=
-# AZURE_OPENAI_ENDPOINT_TTS=
-# AZURE_OPENAI_API_VERSION_TTS=
-
-# USE THIS IF YOU WANT TO DEBUG THE APP ON LANGSMITH
-# LANGCHAIN_TRACING_V2=true
-# LANGCHAIN_ENDPOINT="https://api.smith.langchain.com"
-# LANGCHAIN_API_KEY=
-# LANGCHAIN_PROJECT="Open Notebook"
-
-# CONNECTION DETAILS FOR YOUR SURREAL DB
-# New format (preferred) - WebSocket URL
-SURREAL_URL="ws://surrealdb/rpc:8000"
-SURREAL_USER="root"
-SURREAL_PASSWORD="root"
-SURREAL_NAMESPACE="open_notebook"
-SURREAL_DATABASE="open_notebook"
-
-# RETRY CONFIGURATION (surreal-commands v1.2.0+)
-# Global defaults for all background commands unless explicitly overridden at command level
-# These settings help commands automatically recover from transient failures like:
-# - Database transaction conflicts during concurrent operations
-# - Network timeouts when calling external APIs
-# - Rate limits from LLM/embedding providers
-# - Temporary resource unavailability
-
-# Enable/disable retry globally (default: true)
-# Set to false to disable retries for all commands (useful for debugging)
-SURREAL_COMMANDS_RETRY_ENABLED=true
-
-# Maximum retry attempts before giving up (default: 3)
-# Database operations use 5 attempts (defined per-command)
-# API calls use 3 attempts (defined per-command)
-SURREAL_COMMANDS_RETRY_MAX_ATTEMPTS=3
-
-# Wait strategy between retry attempts (default: exponential_jitter)
-# Options: exponential_jitter, exponential, fixed, random
-# - exponential_jitter: Recommended - prevents thundering herd during DB conflicts
-# - exponential: Good for API rate limits (predictable backoff)
-# - fixed: Use for quick recovery scenarios
-# - random: Use when you want unpredictable retry timing
-SURREAL_COMMANDS_RETRY_WAIT_STRATEGY=exponential_jitter
-
-# Minimum wait time between retries in seconds (default: 1)
-# Database conflicts: 1 second (fast retry for transient issues)
-# API rate limits: 5 seconds (wait for quota reset)
-SURREAL_COMMANDS_RETRY_WAIT_MIN=1
-
-# Maximum wait time between retries in seconds (default: 30)
-# Database conflicts: 30 seconds maximum
-# API rate limits: 120 seconds maximum (defined per-command)
-# Total retry time won't exceed max_attempts * wait_max
-SURREAL_COMMANDS_RETRY_WAIT_MAX=30
-
-# WORKER CONCURRENCY
-# Maximum number of concurrent tasks in the worker pool (default: 5)
-# This affects the likelihood of database transaction conflicts during batch operations
-#
-# Tuning guidelines based on deployment size:
-# - Resource-constrained (low CPU/memory): 1-2 workers
-# Reduces conflicts and resource usage, but slower processing
-#
-# - Normal deployment (balanced): 5 workers (RECOMMENDED)
-# Good balance between throughput and conflict rate
-# Retry logic handles occasional conflicts gracefully
-#
-# - Large instances (high CPU/memory): 10-20 workers
-# Higher throughput but more frequent DB conflicts
-# Relies heavily on retry logic with jittered backoff
-#
-# Note: Higher concurrency increases vectorization speed but also increases
-# SurrealDB transaction conflicts. The retry logic with exponential-jitter
-# backoff ensures operations complete successfully even at high concurrency.
-SURREAL_COMMANDS_MAX_TASKS=5
-
-# OPEN_NOTEBOOK_PASSWORD=
-
-# FIRECRAWL - Get a key at https://firecrawl.dev/
-FIRECRAWL_API_KEY=
-
-# JINA - Get a key at https://jina.ai/
-JINA_API_KEY=
\ No newline at end of file
+# For more configuration options, see:
+# https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/environment-reference.md
diff --git a/.gitignore b/.gitignore
index 1100153..c280d0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -131,6 +131,7 @@ doc_exports/
specs/
.claude
+.sisyphus
.playwright-mcp/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b892bc..3512e2c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,13 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.7.0-rc1] - 2026-02-07
+
### Added
+- **Credential-Based Provider Management** (#477)
+ - New Settings → API Keys page for managing AI provider credentials via the UI
+ - Support for 14 providers: OpenAI, Anthropic, Google, Groq, Mistral, DeepSeek, xAI, OpenRouter, Voyage AI, ElevenLabs, Ollama, Azure OpenAI, OpenAI-Compatible, and Vertex AI
+ - Secure storage of API keys in SurrealDB with field-level encryption (Fernet AES-128-CBC + HMAC-SHA256)
+ - One-click connection testing, model discovery, and model registration per credential
+ - Migration tool to import existing environment variable keys into the credential system
+ - Azure OpenAI support with service-specific endpoints (LLM, Embedding, STT, TTS)
+ - OpenAI-Compatible support with per-service URL configurations
+ - Vertex AI support with project, location, and credentials path
+ - Environment variable API keys deprecated in favor of Settings UI
+
+- **Security Enhancements**
+ - Docker secrets support via `_FILE` suffix pattern (e.g., `OPEN_NOTEBOOK_PASSWORD_FILE`)
+ - Default encryption key derived from "0p3n-N0t3b0ok" for easy setup (change in production!)
+ - Default password "open-notebook-change-me" for out-of-box experience (change in production!)
+ - URL validation for SSRF protection - blocks private IPs and localhost (except for Ollama which runs locally)
+ - Security warnings logged when using default credentials
+
- HTML clipboard detection for text sources (#426)
- When pasting content, automatically detects HTML format (e.g., from Word, web pages)
- Shows info message when HTML is detected, informing user it will be converted to Markdown
- Preserves formatting that would be lost with plain text paste
- Bump content-core to 0.11.0 for HTML to Markdown conversion support
+### Fixed
+- Azure form race condition: all configuration now saved in single atomic request
+- Migration API "error error" display: added proper MigrationResult model with message field
+- Connection tester for Ollama providers: improved error handling and URL validation
+
+### Security
+- API keys are encrypted at rest using Fernet symmetric encryption
+- Keys are never returned to the frontend, only configuration status
+- SSRF protection prevents internal network access via URL validation
+
+### Docs
+- Complete documentation update for credential-based system across 25 files
+- All quick-start, installation, and configuration guides now use Settings UI workflow
+- Environment variable API key instructions moved to deprecated/legacy sections
+- Fixed broken links in installation docs
+
## [1.6.2] - 2026-01-24
### Fixed
diff --git a/CLAUDE.md b/CLAUDE.md
index d05329a..39c3bc3 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -36,7 +36,7 @@ This file provides architectural guidance for contributors working on Open Noteb
│ Database (SurrealDB) │
│ Graph database @ port 8000 │
├─────────────────────────────────────────────────────────┤
-│ - Records: Notebook, Source, Note, ChatSession, etc. │
+│ - Records: Notebook, Source, Note, ChatSession, Credential│
│ - Relationships: source-to-notebook, note-to-source │
│ - Vector embeddings for semantic search │
└─────────────────────────────────────────────────────────┘
@@ -98,7 +98,8 @@ User documentation is at @docs/
### 3. Multi-Provider AI
- **Esperanto library**: Unified interface to 8+ AI providers
-- **ModelManager**: Factory pattern with fallback logic
+- **Credential system**: Individual encrypted credential records per provider; models link to credentials for direct config
+- **ModelManager**: Factory pattern with fallback logic; uses credential config when available, env vars as fallback
- **Smart selection**: Detects large contexts, prefers long-context models
- **Override support**: Per-request model configuration
diff --git a/README.md b/README.md
index cb24db4..a078dc2 100644
--- a/README.md
+++ b/README.md
@@ -94,45 +94,86 @@ Learn more about our project at [https://www.open-notebook.ai](https://www.open-
[![Python][Python]][Python-url] [![Next.js][Next.js]][Next-url] [![React][React]][React-url] [![SurrealDB][SurrealDB]][SurrealDB-url] [![LangChain][LangChain]][LangChain-url]
-## 🚀 Quick Start
+## 🚀 Quick Start (2 Minutes)
-Choose your installation method:
+### Prerequisites
+- [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed
+- That's it! (API keys configured later in the UI)
-### 🐳 **Docker (Recommended)**
+### Step 1: Get docker-compose.yml
-**Best for most users** - Fast setup with Docker Compose:
+**Option A:** Download directly
+```bash
+curl -o docker-compose.yml https://raw.githubusercontent.com/lfnovo/open-notebook/main/docker-compose.yml
+```
-→ **[Docker Compose Installation Guide](docs/1-INSTALLATION/docker-compose.md)**
-- Multi-container setup (recommended)
-- 5-10 minutes setup time
-- Requires Docker Desktop
+**Option B:** Create the file manually
+Copy this into a new file called `docker-compose.yml`:
-**Quick Start:**
-- Get an API key (OpenAI, Anthropic, Google, etc.) or setup Ollama
-- Create docker-compose.yml (example in guide)
-- Run: docker compose up -d
-- Access: http://localhost:8502
+```yaml
+services:
+ surrealdb:
+ image: surrealdb/surrealdb:v2
+ command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
+ user: root
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./surreal_data:/mydata
+ restart: always
+
+ open_notebook:
+ image: lfnovo/open_notebook:v1-latest
+ ports:
+ - "8502:8502"
+ - "5055:5055"
+ environment:
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
+ - SURREAL_URL=ws://surrealdb:8000/rpc
+ - SURREAL_USER=root
+ - SURREAL_PASSWORD=root
+ volumes:
+ - ./notebook_data:/app/data
+ depends_on:
+ - surrealdb
+ restart: always
+```
+
+### Step 2: Set Your Encryption Key
+Edit `docker-compose.yml` and change this line:
+```yaml
+- OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
+```
+to any secret value (e.g., `my-super-secret-key-123`)
+
+### Step 3: Start Services
+```bash
+docker compose up -d
+```
+
+Wait 15-20 seconds, then open: **http://localhost:8502**
+
+### Step 4: Configure AI Provider
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Choose your provider (OpenAI, Anthropic, Google, etc.)
+4. Paste your API key and click **Save**
+5. Click **Test Connection** → **Discover Models** → **Register Models**
+
+Done! You're ready to create your first notebook.
+
+> **Need an API key?** Get one from:
+> [OpenAI](https://platform.openai.com/api-keys) · [Anthropic](https://console.anthropic.com/) · [Google](https://aistudio.google.com/) · [Groq](https://console.groq.com/) (free tier)
+
+> **Want free local AI?** See [examples/docker-compose-ollama.yml](examples/) for Ollama setup
---
-### 💻 **From Source (Developers)**
+### 📚 More Installation Options
-**For development and contributors:**
-
-→ **[From Source Installation Guide](docs/1-INSTALLATION/from-source.md)**
-- Clone and run locally
-- 10-15 minutes setup time
-- Requires: Python 3.11+, Node.js 18+, Docker, uv
-
-**Quick Start:**
-
-```bash
-git clone https://github.com/lfnovo/open-notebook.git
-uv sync
-make start-all
-```
-
-Access: http://localhost:3000 (dev) or http://localhost:8502 (production)
+- **[With Ollama (Free Local AI)](examples/docker-compose-ollama.yml)** - Run models locally without API costs
+- **[From Source (Developers)](docs/1-INSTALLATION/from-source.md)** - For development and contributions
+- **[Complete Installation Guide](docs/1-INSTALLATION/index.md)** - All deployment scenarios
---
diff --git a/api/CLAUDE.md b/api/CLAUDE.md
index 6620970..9c14ca9 100644
--- a/api/CLAUDE.md
+++ b/api/CLAUDE.md
@@ -58,6 +58,7 @@ FastAPI application serving three architectural layers: routes (HTTP endpoints),
- **routers/notes.py**: POST /notes, GET /notes/{id}
- **routers/sources.py**: POST /sources, GET /sources/{id}, DELETE /sources/{id}
- **routers/models.py**: GET /models, POST /models/config
+- **routers/credentials.py**: CRUD + test + discover + migrate for credential management
- **routers/transformations.py**: POST /transformations
- **routers/insights.py**: GET /sources/{source_id}/insights
- **routers/auth.py**: POST /auth/password (password-based auth)
@@ -115,3 +116,114 @@ FastAPI application serving three architectural layers: routes (HTTP endpoints),
- **Direct service tests**: Import service, call methods directly with test data
- **Mock graphs**: Replace graph.ainvoke() with mock for testing service logic
- **Database: Use test database** (separate SurrealDB instance or mock repo_query)
+
+---
+
+## Credential Management (API Configuration UI)
+
+The Credential Management system enables users to configure AI provider credentials through the UI instead of environment variables. Keys are stored securely in SurrealDB (encrypted via Fernet) with database-first fallback to environment variables.
+
+### Router: `routers/credentials.py`
+
+**Endpoints**:
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/credentials` | List all credentials (optional `?provider=` filter) |
+| GET | `/credentials/by-provider/{provider}` | List credentials for a provider |
+| POST | `/credentials` | Create a new credential |
+| GET | `/credentials/{credential_id}` | Get a specific credential |
+| PUT | `/credentials/{credential_id}` | Update a credential |
+| DELETE | `/credentials/{credential_id}` | Delete a credential |
+| POST | `/credentials/{credential_id}/test` | Test connection using credential |
+| POST | `/credentials/{credential_id}/discover` | Discover available models |
+| POST | `/credentials/{credential_id}/register-models` | Register discovered models |
+| POST | `/credentials/migrate-from-provider-config` | Migrate from legacy ProviderConfig |
+
+**Supported Providers** (13 total):
+- Simple API key: `openai`, `anthropic`, `google`, `groq`, `mistral`, `deepseek`, `xai`, `openrouter`, `voyage`, `elevenlabs`
+- URL-based: `ollama`
+- Multi-field: `azure`, `vertex`, `openai_compatible`
+
+**Security Features**:
+- NEVER returns actual API key values (only metadata)
+- URL validation (SSRF protection) on all URL fields via `_validate_url()`
+- Allows private IPs and localhost for self-hosted services (Ollama, LM Studio)
+- Requires `OPEN_NOTEBOOK_ENCRYPTION_KEY` to be set for storing credentials
+
+### Domain Model: `Credential` (`open_notebook/domain/credential.py`)
+
+Individual credential records replacing the old `ProviderConfig` singleton. Each credential stores:
+- Provider name, display name, modalities
+- Encrypted API key (via Fernet)
+- Provider-specific config (base_url, endpoint, api_version, etc.)
+
+### Integration with Key Provider (`open_notebook/ai/key_provider.py`)
+
+The `key_provider` module provisions DB-stored credentials into environment variables for Esperanto compatibility:
+
+**Database-first Pattern**:
+1. API endpoint saves keys to `Credential` records (encrypted in SurrealDB)
+2. Before model provisioning, `provision_provider_keys(provider)` checks DB, then env vars
+3. Keys from DB are set as environment variables for Esperanto compatibility
+4. Existing env vars remain unchanged if no DB config exists
+
+**Key Functions**:
+- `get_api_key(provider)`: Get API key (DB first, env fallback)
+- `provision_provider_keys(provider)`: Set env vars from DB for a provider
+- `provision_all_keys()`: Load all provider keys from DB into env vars
+
+### Authentication
+
+No changes to authentication. The `credentials` router uses the same `PasswordAuthMiddleware` as all other endpoints. Keys are protected by the same password-based auth.
+
+**Auth Flow** (unchanged from `api/auth.py`):
+- `PasswordAuthMiddleware`: Global middleware checking `Authorization: Bearer {password}` header
+- Default password: `open-notebook-change-me` (set `OPEN_NOTEBOOK_PASSWORD` in production)
+- Docker secrets support via `OPEN_NOTEBOOK_PASSWORD_FILE`
+
+### Connection Testing (`open_notebook/ai/connection_tester.py`)
+
+The `/credentials/{credential_id}/test` endpoint uses minimal API calls to verify credentials:
+- Loads Credential via `Credential.get(config_id)`, uses `credential.to_esperanto_config()`
+- Uses cheapest/smallest models per provider (TEST_MODELS map)
+- Returns success status and descriptive message
+- Special handlers for ollama, openai_compatible, and azure providers
+
+### Migration Workflows
+
+Two migration endpoints help users transition to the credential system:
+
+**From environment variables** (`POST /credentials/migrate-from-env`):
+1. Checks each provider for env var presence
+2. Creates Credential records from env var values
+3. Returns summary: migrated, skipped, errors
+
+**From legacy ProviderConfig** (`POST /credentials/migrate-from-provider-config`):
+1. Reads old ProviderConfig records from database
+2. Converts each to individual Credential records
+3. Returns summary: migrated, skipped, errors
+
+### Example Usage
+
+```python
+# Check status
+GET /credentials/status
+# Response: {"configured": {"openai": true, "anthropic": false}, "source": {"openai": "database", "anthropic": "none"}, "encryption_configured": true}
+
+# Create credential
+POST /credentials
+{"name": "My OpenAI Key", "provider": "openai", "modalities": ["language", "embedding"], "api_key": "sk-proj-..."}
+
+# Test connection
+POST /credentials/{credential_id}/test
+# Response: {"provider": "openai", "success": true, "message": "Connection successful"}
+
+# Discover models
+POST /credentials/{credential_id}/discover
+# Response: {"provider": "openai", "models": [{"model_id": "gpt-4", "name": "gpt-4", ...}], "credential_id": "..."}
+
+# Migrate from env
+POST /credentials/migrate-from-env
+# Response: {"message": "Migration complete. Migrated 3 providers.", "migrated": ["openai", "anthropic", "groq"], "skipped": [], "errors": []}
+```
diff --git a/api/auth.py b/api/auth.py
index 0ad2378..b89058b 100644
--- a/api/auth.py
+++ b/api/auth.py
@@ -1,21 +1,24 @@
-import os
from typing import Optional
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from loguru import logger
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
+from open_notebook.utils.encryption import get_secret_from_env
+
class PasswordAuthMiddleware(BaseHTTPMiddleware):
"""
Middleware to check password authentication for all API requests.
- Only active when OPEN_NOTEBOOK_PASSWORD environment variable is set.
+ Always active with default password if OPEN_NOTEBOOK_PASSWORD is not set.
+ Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
"""
def __init__(self, app, excluded_paths: Optional[list] = None):
super().__init__(app)
- self.password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
+ self.password = get_secret_from_env("OPEN_NOTEBOOK_PASSWORD")
self.excluded_paths = excluded_paths or [
"/",
"/health",
@@ -82,10 +85,13 @@ def check_api_password(
"""
Utility function to check API password.
Can be used as a dependency in individual routes if needed.
+ Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
+ Returns True without checking credentials if OPEN_NOTEBOOK_PASSWORD is not configured.
+ Raises 401 if credentials are missing or don't match the configured password.
"""
- password = os.environ.get("OPEN_NOTEBOOK_PASSWORD")
+ password = get_secret_from_env("OPEN_NOTEBOOK_PASSWORD")
- # No password set, allow access
+ # No password configured - skip authentication
if not password:
return True
diff --git a/api/credentials_service.py b/api/credentials_service.py
new file mode 100644
index 0000000..2545705
--- /dev/null
+++ b/api/credentials_service.py
@@ -0,0 +1,883 @@
+"""
+Credentials Service
+
+Business logic for managing AI provider credentials.
+Extracted from the credentials router to follow the service layer pattern.
+
+All functions raise ValueError for business errors (router converts to HTTPException).
+"""
+
+import ipaddress
+import os
+import socket
+from typing import Dict, List, Optional
+from urllib.parse import urlparse
+
+import httpx
+from loguru import logger
+from pydantic import SecretStr
+
+from api.models import CredentialResponse
+from open_notebook.domain.credential import Credential
+from open_notebook.utils.encryption import get_secret_from_env
+
+# =============================================================================
+# Constants
+# =============================================================================
+
+# Provider environment variable configuration.
+# - "required": ALL listed env vars must be set for the provider to be considered configured.
+# - "required_any": at least ONE of the listed env vars must be set.
+# - "optional": additional env vars used during migration but not required.
+PROVIDER_ENV_CONFIG: Dict[str, dict] = {
+ "openai": {"required": ["OPENAI_API_KEY"]},
+ "anthropic": {"required": ["ANTHROPIC_API_KEY"]},
+ "google": {"required_any": ["GOOGLE_API_KEY", "GEMINI_API_KEY"]},
+ "groq": {"required": ["GROQ_API_KEY"]},
+ "mistral": {"required": ["MISTRAL_API_KEY"]},
+ "deepseek": {"required": ["DEEPSEEK_API_KEY"]},
+ "xai": {"required": ["XAI_API_KEY"]},
+ "openrouter": {"required": ["OPENROUTER_API_KEY"]},
+ "voyage": {"required": ["VOYAGE_API_KEY"]},
+ "elevenlabs": {"required": ["ELEVENLABS_API_KEY"]},
+ "ollama": {"required": ["OLLAMA_API_BASE"]},
+ "vertex": {
+ "required": ["VERTEX_PROJECT", "VERTEX_LOCATION"],
+ "optional": ["GOOGLE_APPLICATION_CREDENTIALS"],
+ },
+ "azure": {
+ "required": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_ENDPOINT", "AZURE_OPENAI_API_VERSION"],
+ "optional": [
+ "AZURE_OPENAI_ENDPOINT_LLM",
+ "AZURE_OPENAI_ENDPOINT_EMBEDDING",
+ "AZURE_OPENAI_ENDPOINT_STT",
+ "AZURE_OPENAI_ENDPOINT_TTS",
+ ],
+ },
+ "openai_compatible": {
+ "required_any": ["OPENAI_COMPATIBLE_BASE_URL", "OPENAI_COMPATIBLE_API_KEY"],
+ },
+}
+
+PROVIDER_MODALITIES: Dict[str, List[str]] = {
+ "openai": ["language", "embedding", "speech_to_text", "text_to_speech"],
+ "anthropic": ["language"],
+ "google": ["language", "embedding"],
+ "groq": ["language", "speech_to_text"],
+ "mistral": ["language", "embedding"],
+ "deepseek": ["language"],
+ "xai": ["language"],
+ "openrouter": ["language"],
+ "voyage": ["embedding"],
+ "elevenlabs": ["text_to_speech"],
+ "ollama": ["language", "embedding"],
+ "vertex": ["language", "embedding"],
+ "azure": ["language", "embedding", "speech_to_text", "text_to_speech"],
+ "openai_compatible": ["language", "embedding", "speech_to_text", "text_to_speech"],
+}
+
+
+# =============================================================================
+# URL Validation (SSRF protection)
+# =============================================================================
+
+
+def validate_url(url: str, provider: str) -> None:
+ """
+ Validate URL format for API endpoints.
+
+ This is a self-hosted application, so we allow:
+ - Private IPs (10.x, 172.16-31.x, 192.168.x) for self-hosted services
+ - Localhost for local services (Ollama, LM Studio, etc.)
+
+ We only block:
+ - Invalid schemes (must be http or https)
+ - Malformed URLs
+ - Link-local addresses (169.254.x.x) - used for cloud metadata endpoints
+ - Hostnames that resolve to link-local addresses
+
+ Args:
+ url: The URL to validate
+ provider: The provider name (for logging/context)
+
+ Raises:
+ ValueError: If the URL is invalid
+ """
+ if not url or not url.strip():
+ return # Empty URLs handled elsewhere
+
+ try:
+ parsed = urlparse(url.strip())
+
+ # Validate scheme - only http/https allowed
+ if parsed.scheme not in ("http", "https"):
+ raise ValueError(
+ f"Invalid URL scheme: '{parsed.scheme}'. Only http and https are allowed."
+ )
+
+ # Extract hostname
+ hostname = parsed.hostname
+ if not hostname:
+ raise ValueError("Invalid URL: hostname could not be determined.")
+
+ # Try to parse as IP address to check for dangerous addresses
+ try:
+ ip = ipaddress.ip_address(hostname)
+
+ # Block link-local addresses (169.254.x.x) - used for cloud metadata
+ # These are dangerous as they can expose cloud instance credentials
+ if ip.is_link_local:
+ raise ValueError(
+ "Link-local addresses (169.254.x.x) are not allowed for security reasons. "
+ "These addresses are used for cloud metadata endpoints."
+ )
+
+ # Block IPv4-mapped IPv6 addresses pointing to link-local
+ # e.g. ::ffff:169.254.169.254 bypasses IPv6 is_link_local check
+ if hasattr(ip, "ipv4_mapped") and ip.ipv4_mapped and ip.ipv4_mapped.is_link_local:
+ raise ValueError(
+ "Link-local addresses (169.254.x.x) are not allowed for security reasons. "
+ "These addresses are used for cloud metadata endpoints."
+ )
+
+ except ValueError as ve:
+ # Re-raise our own ValueErrors
+ if "Link-local" in str(ve) or "Invalid URL" in str(ve):
+ raise
+ # Not an IP address, it's a hostname - need to resolve and check
+ try:
+ # Resolve hostname to IP address
+ resolved_ips = socket.getaddrinfo(hostname, None)
+ for family, _, _, _, sockaddr in resolved_ips:
+ ip_addr = sockaddr[0]
+ try:
+ parsed_ip = ipaddress.ip_address(ip_addr)
+ if parsed_ip.is_link_local:
+ raise ValueError(
+ f"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. "
+ "These addresses are used for cloud metadata endpoints."
+ )
+ # Block IPv4-mapped IPv6 addresses pointing to link-local
+ if (
+ hasattr(parsed_ip, "ipv4_mapped")
+ and parsed_ip.ipv4_mapped
+ and parsed_ip.ipv4_mapped.is_link_local
+ ):
+ raise ValueError(
+ f"Hostname '{hostname}' resolves to a link-local address (169.254.x.x) which is not allowed for security reasons. "
+ "These addresses are used for cloud metadata endpoints."
+ )
+ except ValueError as inner_ve:
+ if "link-local" in str(inner_ve).lower() or "Link-local" in str(inner_ve):
+ raise
+ # Skip non-IP addresses (e.g., IPv6 zones)
+ continue
+ except socket.gaierror:
+ # Could not resolve hostname - allow it since the URL may be
+ # valid in the deployment environment (e.g., Azure endpoints,
+ # internal DNS names). We only block link-local addresses.
+ pass
+
+ except ValueError:
+ raise
+ except Exception:
+ raise ValueError("Invalid URL format. Check server logs for details.")
+
+
+# =============================================================================
+# Helpers
+# =============================================================================
+
+
+def require_encryption_key() -> None:
+ """Raise ValueError if encryption key is not configured."""
+ if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
+ raise ValueError(
+ "Encryption key not configured. "
+ "Set OPEN_NOTEBOOK_ENCRYPTION_KEY to enable storing API keys."
+ )
+
+
+def credential_to_response(cred: Credential, model_count: int = 0) -> CredentialResponse:
+ """Convert a Credential domain object to API response."""
+ return CredentialResponse(
+ id=cred.id or "",
+ name=cred.name,
+ provider=cred.provider,
+ modalities=cred.modalities,
+ base_url=cred.base_url,
+ endpoint=cred.endpoint,
+ api_version=cred.api_version,
+ endpoint_llm=cred.endpoint_llm,
+ endpoint_embedding=cred.endpoint_embedding,
+ endpoint_stt=cred.endpoint_stt,
+ endpoint_tts=cred.endpoint_tts,
+ project=cred.project,
+ location=cred.location,
+ credentials_path=cred.credentials_path,
+ has_api_key=cred.api_key is not None,
+ created=str(cred.created) if cred.created else "",
+ updated=str(cred.updated) if cred.updated else "",
+ model_count=model_count,
+ )
+
+
+def check_env_configured(provider: str) -> bool:
+ """Check if a provider has sufficient env vars configured for migration."""
+ config = PROVIDER_ENV_CONFIG.get(provider)
+ if not config:
+ return False
+
+ if "required_any" in config:
+ return any(bool(os.environ.get(v, "").strip()) for v in config["required_any"])
+ elif "required" in config:
+ return all(bool(os.environ.get(v, "").strip()) for v in config["required"])
+ return False
+
+
+def get_default_modalities(provider: str) -> List[str]:
+ """Get default modalities for a provider."""
+ return PROVIDER_MODALITIES.get(provider.lower(), ["language"])
+
+
+def create_credential_from_env(provider: str) -> Credential:
+ """Create a Credential from environment variables for a given provider."""
+ modalities = get_default_modalities(provider)
+ name = "Default (Migrated from env)"
+
+ if provider == "ollama":
+ return Credential(
+ name=name,
+ provider=provider,
+ modalities=modalities,
+ base_url=os.environ.get("OLLAMA_API_BASE"),
+ )
+ elif provider == "vertex":
+ return Credential(
+ name=name,
+ provider=provider,
+ modalities=modalities,
+ project=os.environ.get("VERTEX_PROJECT"),
+ location=os.environ.get("VERTEX_LOCATION"),
+ credentials_path=os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"),
+ )
+ elif provider == "azure":
+ return Credential(
+ name=name,
+ provider=provider,
+ modalities=modalities,
+ api_key=SecretStr(os.environ["AZURE_OPENAI_API_KEY"]),
+ endpoint=os.environ.get("AZURE_OPENAI_ENDPOINT"),
+ api_version=os.environ.get("AZURE_OPENAI_API_VERSION"),
+ endpoint_llm=os.environ.get("AZURE_OPENAI_ENDPOINT_LLM"),
+ endpoint_embedding=os.environ.get("AZURE_OPENAI_ENDPOINT_EMBEDDING"),
+ endpoint_stt=os.environ.get("AZURE_OPENAI_ENDPOINT_STT"),
+ endpoint_tts=os.environ.get("AZURE_OPENAI_ENDPOINT_TTS"),
+ )
+ elif provider == "openai_compatible":
+ api_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY")
+ return Credential(
+ name=name,
+ provider=provider,
+ modalities=modalities,
+ api_key=SecretStr(api_key) if api_key else None,
+ base_url=os.environ.get("OPENAI_COMPATIBLE_BASE_URL"),
+ )
+ elif provider == "google":
+ # Support both GOOGLE_API_KEY and GEMINI_API_KEY (fallback)
+ api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
+ return Credential(
+ name=name,
+ provider=provider,
+ modalities=modalities,
+ api_key=SecretStr(api_key) if api_key else None,
+ )
+ else:
+ # Simple API key providers
+ config = PROVIDER_ENV_CONFIG.get(provider, {})
+ required = config.get("required", [])
+ env_var = required[0] if required else None
+ api_key = os.environ.get(env_var) if env_var else None
+ return Credential(
+ name=name,
+ provider=provider,
+ modalities=modalities,
+ api_key=SecretStr(api_key) if api_key else None,
+ )
+
+
+# =============================================================================
+# Service Functions
+# =============================================================================
+
+
+async def get_provider_status() -> dict:
+ """
+ Get configuration status: encryption key status, and per-provider
+ configured/source information.
+ """
+ encryption_configured = bool(get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"))
+
+ configured: Dict[str, bool] = {}
+ source: Dict[str, str] = {}
+
+ for provider in PROVIDER_ENV_CONFIG:
+ env_configured = check_env_configured(provider)
+ try:
+ db_credentials = await Credential.get_by_provider(provider)
+ db_configured = len(db_credentials) > 0
+ except Exception:
+ db_configured = False
+
+ configured[provider] = db_configured or env_configured
+
+ if db_configured:
+ source[provider] = "database"
+ elif env_configured:
+ source[provider] = "environment"
+ else:
+ source[provider] = "none"
+
+ return {
+ "configured": configured,
+ "source": source,
+ "encryption_configured": encryption_configured,
+ }
+
+
+async def get_env_status() -> Dict[str, bool]:
+ """Check what's configured via environment variables."""
+ env_status: Dict[str, bool] = {}
+ for provider in PROVIDER_ENV_CONFIG:
+ env_status[provider] = check_env_configured(provider)
+ return env_status
+
+
+async def test_credential(credential_id: str) -> dict:
+ """
+ Test connection using a credential's configuration.
+
+ Returns dict with provider, success, message keys.
+ """
+ provider = "unknown"
+ try:
+ cred = await Credential.get(credential_id)
+ config = cred.to_esperanto_config()
+
+ from open_notebook.ai.connection_tester import (
+ _test_azure_connection,
+ _test_ollama_connection,
+ _test_openai_compatible_connection,
+ )
+
+ provider = cred.provider.lower()
+
+ # Handle special providers
+ if provider == "ollama":
+ base_url = config.get("base_url", "http://localhost:11434")
+ success, message = await _test_ollama_connection(base_url)
+ return {"provider": provider, "success": success, "message": message}
+
+ if provider == "openai_compatible":
+ base_url = config.get("base_url")
+ api_key = config.get("api_key")
+ if not base_url:
+ return {
+ "provider": provider,
+ "success": False,
+ "message": "No base URL configured",
+ }
+ success, message = await _test_openai_compatible_connection(
+ base_url, api_key
+ )
+ return {"provider": provider, "success": success, "message": message}
+
+ if provider == "azure":
+ success, message = await _test_azure_connection(
+ endpoint=config.get("endpoint"),
+ api_key=config.get("api_key"),
+ api_version=config.get("api_version"),
+ )
+ return {"provider": provider, "success": success, "message": message}
+
+ # Standard provider: use Esperanto to create and test
+ from esperanto.factory import AIFactory
+
+ from open_notebook.ai.connection_tester import TEST_MODELS
+
+ if provider not in TEST_MODELS:
+ return {
+ "provider": provider,
+ "success": False,
+ "message": f"Unknown provider: {provider}",
+ }
+
+ test_model, test_type = TEST_MODELS[provider]
+ if not test_model:
+ return {
+ "provider": provider,
+ "success": False,
+ "message": f"No test model configured for {provider}",
+ }
+
+ if test_type == "language":
+ model = AIFactory.create_language(
+ model_name=test_model, provider=provider, config=config
+ )
+ lc_model = model.to_langchain()
+ await lc_model.ainvoke("Hi")
+ return {"provider": provider, "success": True, "message": "Connection successful"}
+
+ elif test_type == "embedding":
+ model = AIFactory.create_embedding(
+ model_name=test_model, provider=provider, config=config
+ )
+ await model.aembed(["test"])
+ return {"provider": provider, "success": True, "message": "Connection successful"}
+
+ elif test_type == "text_to_speech":
+ AIFactory.create_text_to_speech(model_name=test_model, provider=provider, config=config)
+ return {
+ "provider": provider,
+ "success": True,
+ "message": "Connection successful (key format valid)",
+ }
+
+ return {
+ "provider": provider,
+ "success": False,
+ "message": f"Unsupported test type: {test_type}",
+ }
+
+ except Exception as e:
+ error_msg = str(e)
+ if "401" in error_msg or "unauthorized" in error_msg.lower():
+ return {"provider": provider, "success": False, "message": "Invalid API key"}
+ elif "403" in error_msg or "forbidden" in error_msg.lower():
+ return {"provider": provider, "success": False, "message": "API key lacks required permissions"}
+ elif "rate" in error_msg.lower() and "limit" in error_msg.lower():
+ return {"provider": provider, "success": True, "message": "Rate limited - but connection works"}
+ elif "not found" in error_msg.lower() and "model" in error_msg.lower():
+ return {"provider": provider, "success": True, "message": "API key valid (test model not available)"}
+ else:
+ logger.debug(f"Test connection error for credential {credential_id}: {e}")
+ truncated = error_msg[:100] + "..." if len(error_msg) > 100 else error_msg
+ return {"provider": provider, "success": False, "message": f"Error: {truncated}"}
+
+
+async def discover_with_config(provider: str, config: dict) -> List[dict]:
+ """
+ Discover models using explicit config instead of env vars.
+
+ Returns model names only — no type classification.
+ The user chooses the model type when registering.
+ """
+ api_key = config.get("api_key")
+ base_url = config.get("base_url")
+
+ # Static model lists for providers without a listing API
+ STATIC_MODELS: Dict[str, List[str]] = {
+ "anthropic": [
+ "claude-opus-4-20250514",
+ "claude-sonnet-4-20250514",
+ "claude-3-5-sonnet-20241022",
+ "claude-3-5-haiku-20241022",
+ "claude-3-opus-20240229",
+ "claude-3-sonnet-20240229",
+ "claude-3-haiku-20240307",
+ ],
+ "voyage": [
+ "voyage-3", "voyage-3-lite", "voyage-code-3",
+ "voyage-finance-2", "voyage-law-2", "voyage-multilingual-2",
+ ],
+ "elevenlabs": [
+ "eleven_multilingual_v2", "eleven_turbo_v2_5",
+ "eleven_turbo_v2", "eleven_monolingual_v1",
+ ],
+ }
+
+ if provider in STATIC_MODELS:
+ if not api_key and provider != "ollama":
+ return []
+ return [
+ {"name": m, "provider": provider}
+ for m in STATIC_MODELS[provider]
+ ]
+
+ # API-based discovery URLs (OpenAI-style /models endpoints)
+ url_map = {
+ "openai": "https://api.openai.com/v1/models",
+ "groq": "https://api.groq.com/openai/v1/models",
+ "mistral": "https://api.mistral.ai/v1/models",
+ "deepseek": "https://api.deepseek.com/models",
+ "xai": "https://api.x.ai/v1/models",
+ "openrouter": "https://openrouter.ai/api/v1/models",
+ }
+
+ if provider == "ollama":
+ ollama_url = base_url or "http://localhost:11434"
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(f"{ollama_url}/api/tags", timeout=10.0)
+ response.raise_for_status()
+ data = response.json()
+ return [
+ {"name": m.get("name", ""), "provider": "ollama"}
+ for m in data.get("models", [])
+ if m.get("name")
+ ]
+ except Exception as e:
+ logger.warning(f"Failed to discover Ollama models: {e}")
+ return []
+
+ if provider == "openai_compatible":
+ if not base_url:
+ return []
+ try:
+ headers = {}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ f"{base_url.rstrip('/')}/models", headers=headers, timeout=30.0,
+ )
+ response.raise_for_status()
+ data = response.json()
+ return [
+ {"name": m.get("id", ""), "provider": "openai_compatible"}
+ for m in data.get("data", [])
+ if m.get("id")
+ ]
+ except Exception as e:
+ logger.warning(f"Failed to discover openai_compatible models: {e}")
+ return []
+
+ if provider == "azure":
+ endpoint = config.get("endpoint")
+ api_version = config.get("api_version", "2024-06-01")
+ if not endpoint or not api_key:
+ return []
+ try:
+ url = f"{endpoint.rstrip('/')}/openai/models?api-version={api_version}"
+ headers = {"api-key": api_key}
+ async with httpx.AsyncClient() as client:
+ response = await client.get(url, headers=headers, timeout=30.0)
+ response.raise_for_status()
+ data = response.json()
+ return [
+ {"name": m.get("id", ""), "provider": "azure"}
+ for m in data.get("data", [])
+ if m.get("id")
+ ]
+ except Exception as e:
+ logger.warning(f"Failed to discover Azure models: {e}")
+ return []
+
+ if provider == "vertex":
+ # Vertex AI requires service-account OAuth2 for model listing.
+ # Return a curated static list of well-known Vertex models instead.
+ VERTEX_MODELS = [
+ "gemini-2.0-flash",
+ "gemini-2.0-flash-lite",
+ "gemini-1.5-pro",
+ "gemini-1.5-flash",
+ "text-embedding-005",
+ ]
+ return [{"name": m, "provider": "vertex"} for m in VERTEX_MODELS]
+
+ if provider == "google":
+ try:
+ headers = {"X-Goog-Api-Key": api_key} if api_key else {}
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ "https://generativelanguage.googleapis.com/v1/models",
+ headers=headers,
+ timeout=30.0,
+ )
+ response.raise_for_status()
+ data = response.json()
+ return [
+ {
+ "name": model.get("name", "").replace("models/", ""),
+ "provider": "google",
+ "description": model.get("displayName"),
+ }
+ for model in data.get("models", [])
+ if model.get("name")
+ ]
+ except Exception as e:
+ logger.warning(f"Failed to discover Google models: {e}")
+ return []
+
+ # Standard OpenAI-style API discovery
+ discovery_url = url_map.get(provider)
+ if not discovery_url or not api_key:
+ return []
+
+ try:
+ async with httpx.AsyncClient() as client:
+ response = await client.get(
+ discovery_url,
+ headers={"Authorization": f"Bearer {api_key}"},
+ timeout=30.0,
+ )
+ response.raise_for_status()
+ data = response.json()
+
+ return [
+ {
+ "name": m.get("id", ""),
+ "provider": provider,
+ "description": m.get("name"),
+ }
+ for m in data.get("data", [])
+ if m.get("id")
+ ]
+ except Exception as e:
+ logger.warning(f"Failed to discover {provider} models: {e}")
+ return []
+
+
+async def register_models(credential_id: str, models_data: list) -> dict:
+ """
+ Register discovered models and link them to a credential.
+
+ Args:
+ credential_id: The credential ID to link models to
+ models_data: List of dicts with name, provider, model_type
+
+ Returns:
+ dict with created and existing counts
+ """
+ cred = await Credential.get(credential_id)
+
+ from open_notebook.ai.models import Model
+ from open_notebook.database.repository import repo_query
+
+ # Batch fetch existing models for this provider
+ existing_models = await repo_query(
+ "SELECT string::lowercase(name) as name, string::lowercase(type) as type FROM model "
+ "WHERE string::lowercase(provider) = $provider",
+ {"provider": cred.provider.lower()},
+ )
+ existing_keys = {(m["name"], m["type"]) for m in existing_models}
+
+ created = 0
+ existing = 0
+
+ for model_data in models_data:
+ key = (model_data.name.lower(), model_data.model_type.lower())
+ if key in existing_keys:
+ existing += 1
+ continue
+
+ new_model = Model(
+ name=model_data.name,
+ provider=model_data.provider or cred.provider,
+ type=model_data.model_type,
+ credential=cred.id,
+ )
+ await new_model.save()
+ created += 1
+
+ return {"created": created, "existing": existing}
+
+
+async def migrate_from_provider_config() -> dict:
+ """
+ Migrate existing ProviderConfig data to individual credential records.
+
+ Returns dict with message, migrated, skipped, errors.
+ """
+ logger.info("=== Starting ProviderConfig migration ===")
+
+ require_encryption_key()
+ logger.info("Encryption key verified")
+
+ from open_notebook.domain.provider_config import ProviderConfig
+
+ config = await ProviderConfig.get_instance()
+ logger.info(
+ f"Found ProviderConfig with {len(config.credentials)} provider(s): "
+ f"{', '.join(config.credentials.keys())}"
+ )
+
+ migrated = []
+ skipped = []
+ errors = []
+
+ for provider, credentials_list in config.credentials.items():
+ for old_cred in credentials_list:
+ try:
+ # Check if a credential already exists for this provider with same name
+ existing = await Credential.get_by_provider(provider)
+ names = [c.name for c in existing]
+ if old_cred.name in names:
+ logger.info(
+ f"[{provider}/{old_cred.name}] Already exists in DB, skipping"
+ )
+ skipped.append(f"{provider}/{old_cred.name}")
+ continue
+
+ # Determine modalities from the provider type
+ modalities = get_default_modalities(provider)
+
+ logger.info(f"[{provider}/{old_cred.name}] Creating credential")
+ new_cred = Credential(
+ name=old_cred.name,
+ provider=provider,
+ modalities=modalities,
+ api_key=old_cred.api_key,
+ base_url=old_cred.base_url,
+ endpoint=old_cred.endpoint,
+ api_version=old_cred.api_version,
+ endpoint_llm=old_cred.endpoint_llm,
+ endpoint_embedding=old_cred.endpoint_embedding,
+ endpoint_stt=old_cred.endpoint_stt,
+ endpoint_tts=old_cred.endpoint_tts,
+ project=old_cred.project,
+ location=old_cred.location,
+ credentials_path=old_cred.credentials_path,
+ )
+ await new_cred.save()
+ logger.info(
+ f"[{provider}/{old_cred.name}] Credential saved (id={new_cred.id})"
+ )
+
+ # Link existing models for this provider to the new credential
+ from open_notebook.ai.models import Model
+ from open_notebook.database.repository import repo_query
+
+ provider_models = await repo_query(
+ "SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
+ {"provider": provider.lower()},
+ )
+ if provider_models:
+ logger.info(
+ f"[{provider}/{old_cred.name}] Linking {len(provider_models)} "
+ f"unassigned model(s)"
+ )
+ for model_data in provider_models:
+ model = Model(**model_data)
+ model.credential = new_cred.id
+ await model.save()
+
+ migrated.append(f"{provider}/{old_cred.name}")
+
+ except Exception as e:
+ logger.error(
+ f"[{provider}/{old_cred.name}] Migration FAILED: "
+ f"{type(e).__name__}: {e}",
+ exc_info=True,
+ )
+ errors.append(f"{provider}/{old_cred.name}: {e}")
+
+ logger.info(
+ f"=== ProviderConfig migration complete === "
+ f"migrated={len(migrated)} skipped={len(skipped)} errors={len(errors)}"
+ )
+ if migrated:
+ logger.info(f" Migrated: {', '.join(migrated)}")
+ if skipped:
+ logger.info(f" Skipped: {', '.join(skipped)}")
+ if errors:
+ logger.error(f" Errors: {'; '.join(errors)}")
+
+ return {
+ "message": f"Migration complete. Migrated {len(migrated)} credentials.",
+ "migrated": migrated,
+ "skipped": skipped,
+ "errors": errors,
+ }
+
+
+async def migrate_from_env() -> dict:
+ """
+ Migrate API keys from environment variables to credential records.
+
+ Returns dict with message, migrated, skipped, not_configured, errors.
+ """
+ logger.info("=== Starting environment variable migration ===")
+ logger.info(
+ f"Checking {len(PROVIDER_ENV_CONFIG)} providers: "
+ f"{', '.join(PROVIDER_ENV_CONFIG.keys())}"
+ )
+
+ require_encryption_key()
+ logger.info("Encryption key verified")
+
+ from open_notebook.ai.models import Model
+ from open_notebook.database.repository import repo_query
+
+ migrated = []
+ skipped = []
+ not_configured = []
+ errors = []
+
+ for provider in PROVIDER_ENV_CONFIG:
+ try:
+ if not check_env_configured(provider):
+ logger.debug(f"[{provider}] No env vars configured, skipping")
+ not_configured.append(provider)
+ continue
+
+ logger.info(f"[{provider}] Env vars detected, checking for existing credentials")
+
+ existing = await Credential.get_by_provider(provider)
+ if existing:
+ logger.info(
+ f"[{provider}] Already has {len(existing)} credential(s) in DB, skipping"
+ )
+ skipped.append(provider)
+ continue
+
+ logger.info(f"[{provider}] Creating credential from env vars")
+ cred = create_credential_from_env(provider)
+ await cred.save()
+ logger.info(f"[{provider}] Credential saved successfully (id={cred.id})")
+
+ # Link unassigned models to this credential
+ provider_models = await repo_query(
+ "SELECT * FROM model WHERE string::lowercase(provider) = $provider AND credential IS NONE",
+ {"provider": provider.lower()},
+ )
+ if provider_models:
+ logger.info(
+ f"[{provider}] Linking {len(provider_models)} unassigned model(s) "
+ f"to credential {cred.id}"
+ )
+ for model_data in provider_models:
+ model = Model(**model_data)
+ model.credential = cred.id
+ await model.save()
+ else:
+ logger.info(f"[{provider}] No unassigned models to link")
+
+ migrated.append(provider)
+
+ except Exception as e:
+ logger.error(
+ f"[{provider}] Migration FAILED: {type(e).__name__}: {e}",
+ exc_info=True,
+ )
+ errors.append(f"{provider}: {e}")
+
+ logger.info(
+ f"=== Environment variable migration complete === "
+ f"migrated={len(migrated)} skipped={len(skipped)} "
+ f"not_configured={len(not_configured)} errors={len(errors)}"
+ )
+ if migrated:
+ logger.info(f" Migrated: {', '.join(migrated)}")
+ if skipped:
+ logger.info(f" Skipped (already in DB): {', '.join(skipped)}")
+ if errors:
+ logger.error(f" Errors: {'; '.join(errors)}")
+
+ return {
+ "message": f"Migration complete. Migrated {len(migrated)} providers.",
+ "migrated": migrated,
+ "skipped": skipped,
+ "not_configured": not_configured,
+ "errors": errors,
+ }
diff --git a/api/main.py b/api/main.py
index bdc8eb9..df93eb8 100644
--- a/api/main.py
+++ b/api/main.py
@@ -17,6 +17,7 @@ from api.routers import (
chat,
config,
context,
+ credentials,
embedding,
embedding_rebuild,
episode_profiles,
@@ -34,6 +35,7 @@ from api.routers import (
)
from api.routers import commands as commands_router
from open_notebook.database.async_migrate import AsyncMigrationManager
+from open_notebook.utils.encryption import get_secret_from_env
# Import commands to register them in the API process
try:
@@ -48,9 +50,21 @@ async def lifespan(app: FastAPI):
Lifespan event handler for the FastAPI application.
Runs database migrations automatically on startup.
"""
- # Startup: Run database migrations
+ import os
+
+ # Startup: Security checks
logger.info("Starting API initialization...")
+ # Security check: Encryption key
+ if not get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY"):
+ logger.warning(
+ "OPEN_NOTEBOOK_ENCRYPTION_KEY not set. "
+ "API key encryption will fail until this is configured. "
+ "Set OPEN_NOTEBOOK_ENCRYPTION_KEY to any secret string."
+ )
+
+ # Run database migrations
+
try:
migration_manager = AsyncMigrationManager()
current_version = await migration_manager.get_current_version()
@@ -162,6 +176,7 @@ app.include_router(episode_profiles.router, prefix="/api", tags=["episode-profil
app.include_router(speaker_profiles.router, prefix="/api", tags=["speaker-profiles"])
app.include_router(chat.router, prefix="/api", tags=["chat"])
app.include_router(source_chat.router, prefix="/api", tags=["source-chat"])
+app.include_router(credentials.router, prefix="/api", tags=["credentials"])
@app.get("/")
diff --git a/api/models.py b/api/models.py
index 80261b6..b7c5ac8 100644
--- a/api/models.py
+++ b/api/models.py
@@ -1,6 +1,6 @@
from typing import Any, Dict, List, Literal, Optional
-from pydantic import BaseModel, ConfigDict, Field, model_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
# Notebook models
@@ -68,6 +68,9 @@ class ModelCreate(BaseModel):
...,
description="Model type (language, embedding, text_to_speech, speech_to_text)",
)
+ credential: Optional[str] = Field(
+ None, description="Credential ID to link this model to"
+ )
class ModelResponse(BaseModel):
@@ -75,6 +78,7 @@ class ModelResponse(BaseModel):
name: str
provider: str
type: str
+ credential: Optional[str] = None
created: str
updated: str
@@ -434,7 +438,231 @@ class ErrorResponse(BaseModel):
message: str
+# API Key Configuration models
+class SetApiKeyRequest(BaseModel):
+ """Request to set an API key for a provider."""
+
+ api_key: Optional[str] = Field(None, description="API key for the provider")
+ base_url: Optional[str] = Field(
+ None, description="Base URL for URL-based providers (Ollama, OpenAI-compatible)"
+ )
+ endpoint: Optional[str] = Field(
+ None, description="Endpoint URL for Azure OpenAI"
+ )
+ api_version: Optional[str] = Field(
+ None, description="API version for Azure OpenAI"
+ )
+ endpoint_llm: Optional[str] = Field(
+ None, description="Service-specific endpoint for LLM (Azure)"
+ )
+ endpoint_embedding: Optional[str] = Field(
+ None, description="Service-specific endpoint for embedding (Azure)"
+ )
+ endpoint_stt: Optional[str] = Field(
+ None, description="Service-specific endpoint for STT (Azure)"
+ )
+ endpoint_tts: Optional[str] = Field(
+ None, description="Service-specific endpoint for TTS (Azure)"
+ )
+ service_type: Optional[Literal["llm", "embedding", "stt", "tts"]] = Field(
+ None,
+ description="Service type for OpenAI-compatible providers (llm, embedding, stt, tts)",
+ )
+ # Vertex AI specific fields
+ vertex_project: Optional[str] = Field(
+ None, description="Google Cloud Project ID for Vertex AI"
+ )
+ vertex_location: Optional[str] = Field(
+ None, description="Google Cloud Region for Vertex AI (e.g., us-central1)"
+ )
+ vertex_credentials_path: Optional[str] = Field(
+ None, description="Path to Google Cloud service account JSON file"
+ )
+
+ @field_validator(
+ "api_key",
+ "base_url",
+ "endpoint",
+ "api_version",
+ "endpoint_llm",
+ "endpoint_embedding",
+ "endpoint_stt",
+ "endpoint_tts",
+ "vertex_project",
+ "vertex_location",
+ "vertex_credentials_path",
+ mode="before",
+ )
+ @classmethod
+ def validate_not_empty_string(cls, v: Optional[str]) -> Optional[str]:
+ """Reject empty strings - convert to None or raise error."""
+ if v is not None:
+ stripped = v.strip()
+ if not stripped:
+ return None # Treat empty/whitespace-only as None
+ return stripped
+ return v
+
+
+class ApiKeyStatusResponse(BaseModel):
+ """Response showing which providers are configured and their source."""
+
+ configured: Dict[str, bool] = Field(
+ ..., description="Map of provider name to whether it is configured"
+ )
+ source: Dict[str, Literal["database", "environment", "none"]] = Field(
+ ...,
+ description="Map of provider name to configuration source (database, environment, or none)",
+ )
+ encryption_configured: bool = Field(
+ ...,
+ description="Whether OPEN_NOTEBOOK_ENCRYPTION_KEY is set (required to store keys in database)",
+ )
+
+
+class TestConnectionResponse(BaseModel):
+ """Response from testing a provider connection."""
+
+ provider: str = Field(..., description="Provider name that was tested")
+ success: bool = Field(..., description="Whether connection test succeeded")
+ message: str = Field(..., description="Result message with details")
+
+
+class MigrateFromEnvRequest(BaseModel):
+ """Request to migrate API keys from environment variables to database."""
+
+ force: bool = Field(
+ False, description="Force overwrite existing database configurations"
+ )
+
+
+class MigrationResult(BaseModel):
+ """Response from migrating API keys from environment to database."""
+
+ message: str = Field(..., description="Summary message")
+ migrated: List[str] = Field(
+ default_factory=list, description="Providers successfully migrated"
+ )
+ skipped: List[str] = Field(
+ default_factory=list, description="Providers skipped (already in DB)"
+ )
+ errors: List[str] = Field(
+ default_factory=list, description="Migration errors by provider"
+ )
+
+
# Notebook delete cascade models
+# Credential models
+class CreateCredentialRequest(BaseModel):
+ """Request to create a new credential."""
+
+ name: str = Field(..., description="Credential name")
+ provider: str = Field(..., description="Provider name (openai, anthropic, etc.)")
+ modalities: List[str] = Field(
+ default_factory=list,
+ description="Supported modalities (language, embedding, text_to_speech, speech_to_text)",
+ )
+ api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
+ base_url: Optional[str] = Field(None, description="Base URL")
+ endpoint: Optional[str] = Field(None, description="Endpoint URL (Azure)")
+ api_version: Optional[str] = Field(None, description="API version (Azure)")
+ endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
+ endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
+ endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
+ endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
+ project: Optional[str] = Field(None, description="Project ID (Vertex)")
+ location: Optional[str] = Field(None, description="Location (Vertex)")
+ credentials_path: Optional[str] = Field(
+ None, description="Credentials file path (Vertex)"
+ )
+
+
+class UpdateCredentialRequest(BaseModel):
+ """Request to update an existing credential."""
+
+ name: Optional[str] = Field(None, description="Credential name")
+ modalities: Optional[List[str]] = Field(None, description="Supported modalities")
+ api_key: Optional[str] = Field(None, description="API key (stored encrypted)")
+ base_url: Optional[str] = Field(None, description="Base URL")
+ endpoint: Optional[str] = Field(None, description="Endpoint URL")
+ api_version: Optional[str] = Field(None, description="API version")
+ endpoint_llm: Optional[str] = Field(None, description="LLM endpoint")
+ endpoint_embedding: Optional[str] = Field(None, description="Embedding endpoint")
+ endpoint_stt: Optional[str] = Field(None, description="STT endpoint")
+ endpoint_tts: Optional[str] = Field(None, description="TTS endpoint")
+ project: Optional[str] = Field(None, description="Project ID")
+ location: Optional[str] = Field(None, description="Location")
+ credentials_path: Optional[str] = Field(None, description="Credentials path")
+
+
+class CredentialResponse(BaseModel):
+ """Response for a credential (never includes api_key)."""
+
+ id: str
+ name: str
+ provider: str
+ modalities: List[str]
+ base_url: Optional[str] = None
+ endpoint: Optional[str] = None
+ api_version: Optional[str] = None
+ endpoint_llm: Optional[str] = None
+ endpoint_embedding: Optional[str] = None
+ endpoint_stt: Optional[str] = None
+ endpoint_tts: Optional[str] = None
+ project: Optional[str] = None
+ location: Optional[str] = None
+ credentials_path: Optional[str] = None
+ has_api_key: bool = False
+ created: str
+ updated: str
+ model_count: int = 0
+
+
+class CredentialDeleteResponse(BaseModel):
+ """Response for credential deletion."""
+
+ message: str
+ deleted_models: int = 0
+
+
+class DiscoveredModelResponse(BaseModel):
+ """A model discovered from a provider."""
+
+ name: str
+ provider: str
+ model_type: Optional[str] = None
+ description: Optional[str] = None
+
+
+class DiscoverModelsResponse(BaseModel):
+ """Response from model discovery."""
+
+ credential_id: str
+ provider: str
+ discovered: List[DiscoveredModelResponse]
+
+
+class RegisterModelData(BaseModel):
+ """A model to register with user-specified type."""
+
+ name: str
+ provider: str
+ model_type: str # Required: user specifies the type
+
+
+class RegisterModelsRequest(BaseModel):
+ """Request to register discovered models."""
+
+ models: List[RegisterModelData]
+
+
+class RegisterModelsResponse(BaseModel):
+ """Response from model registration."""
+
+ created: int
+ existing: int
+
+
class NotebookDeletePreview(BaseModel):
notebook_id: str = Field(..., description="ID of the notebook")
notebook_name: str = Field(..., description="Name of the notebook")
diff --git a/api/routers/auth.py b/api/routers/auth.py
index 1bcd842..363323a 100644
--- a/api/routers/auth.py
+++ b/api/routers/auth.py
@@ -3,10 +3,10 @@ Authentication router for Open Notebook API.
Provides endpoints to check authentication status.
"""
-import os
-
from fastapi import APIRouter
+from open_notebook.utils.encryption import get_secret_from_env
+
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -15,12 +15,13 @@ async def get_auth_status():
"""
Check if authentication is enabled.
Returns whether a password is required to access the API.
+ Supports Docker secrets via OPEN_NOTEBOOK_PASSWORD_FILE.
"""
- auth_enabled = bool(os.environ.get("OPEN_NOTEBOOK_PASSWORD"))
+ auth_enabled = bool(get_secret_from_env("OPEN_NOTEBOOK_PASSWORD"))
return {
"auth_enabled": auth_enabled,
"message": "Authentication is required"
if auth_enabled
else "Authentication is disabled",
- }
+ }
\ No newline at end of file
diff --git a/api/routers/credentials.py b/api/routers/credentials.py
new file mode 100644
index 0000000..59d0940
--- /dev/null
+++ b/api/routers/credentials.py
@@ -0,0 +1,386 @@
+"""
+Credentials Router
+
+Thin HTTP layer for managing individual AI provider credentials.
+Business logic lives in api.credentials_service.
+
+Endpoints:
+- GET /credentials - List all credentials
+- GET /credentials/by-provider/{provider} - List credentials for a provider
+- POST /credentials - Create a new credential
+- GET /credentials/{credential_id} - Get a specific credential
+- PUT /credentials/{credential_id} - Update a credential
+- DELETE /credentials/{credential_id} - Delete a credential
+- POST /credentials/{credential_id}/test - Test connection
+- POST /credentials/{credential_id}/discover - Discover models
+- POST /credentials/{credential_id}/register-models - Register models
+
+NEVER returns actual API key values - only metadata.
+"""
+
+from typing import List, Optional
+
+from fastapi import APIRouter, HTTPException, Query
+from loguru import logger
+from pydantic import SecretStr
+
+from api.credentials_service import (
+ credential_to_response,
+ discover_with_config,
+ migrate_from_env as svc_migrate_from_env,
+ migrate_from_provider_config as svc_migrate_from_provider_config,
+ register_models,
+ require_encryption_key,
+ test_credential as svc_test_credential,
+ validate_url,
+)
+from api.credentials_service import (
+ get_env_status as svc_get_env_status,
+ get_provider_status,
+)
+from api.models import (
+ CreateCredentialRequest,
+ CredentialDeleteResponse,
+ CredentialResponse,
+ DiscoveredModelResponse,
+ DiscoverModelsResponse,
+ RegisterModelsRequest,
+ RegisterModelsResponse,
+ UpdateCredentialRequest,
+)
+from open_notebook.domain.credential import Credential
+
+router = APIRouter(prefix="/credentials", tags=["credentials"])
+
+
+def _handle_value_error(e: ValueError, status_code: int = 400) -> HTTPException:
+ """Convert a ValueError from the service layer to an HTTPException."""
+ return HTTPException(status_code=status_code, detail=str(e))
+
+
+# =============================================================================
+# Status endpoints
+# =============================================================================
+
+
+@router.get("/status")
+async def get_status():
+ """
+ Get configuration status: encryption key status, and per-provider
+ configured/source information.
+ """
+ try:
+ return await get_provider_status()
+ except Exception as e:
+ logger.error(f"Error fetching status: {e}")
+ raise HTTPException(status_code=500, detail="Failed to fetch credential status")
+
+
+@router.get("/env-status")
+async def get_env_status():
+ """Check what's configured via environment variables."""
+ try:
+ return await svc_get_env_status()
+ except Exception as e:
+ logger.error(f"Error checking env status: {e}")
+ raise HTTPException(status_code=500, detail="Failed to check environment status")
+
+
+# =============================================================================
+# CRUD endpoints
+# =============================================================================
+
+
+@router.get("", response_model=List[CredentialResponse])
+async def list_credentials(
+ provider: Optional[str] = Query(None, description="Filter by provider"),
+):
+ """List all credentials, optionally filtered by provider."""
+ try:
+ if provider:
+ credentials = await Credential.get_by_provider(provider)
+ else:
+ credentials = await Credential.get_all(order_by="provider, created")
+
+ result = []
+ for cred in credentials:
+ models = await cred.get_linked_models()
+ result.append(credential_to_response(cred, len(models)))
+
+ return result
+
+ except Exception as e:
+ logger.error(f"Error listing credentials: {e}")
+ raise HTTPException(status_code=500, detail="Failed to list credentials")
+
+
+@router.get("/by-provider/{provider}", response_model=List[CredentialResponse])
+async def list_credentials_by_provider(provider: str):
+ """List all credentials for a specific provider."""
+ try:
+ credentials = await Credential.get_by_provider(provider.lower())
+ result = []
+ for cred in credentials:
+ models = await cred.get_linked_models()
+ result.append(credential_to_response(cred, len(models)))
+ return result
+ except Exception as e:
+ logger.error(f"Error listing credentials for {provider}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to list credentials for provider")
+
+
+@router.post("", response_model=CredentialResponse, status_code=201)
+async def create_credential(request: CreateCredentialRequest):
+ """Create a new credential."""
+ try:
+ require_encryption_key()
+ except ValueError as e:
+ raise _handle_value_error(e)
+
+ # Validate all URL fields
+ for url_field in [
+ request.base_url, request.endpoint, request.endpoint_llm,
+ request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
+ ]:
+ if url_field:
+ try:
+ validate_url(url_field, request.provider)
+ except ValueError as e:
+ raise _handle_value_error(e)
+
+ try:
+ cred = Credential(
+ name=request.name,
+ provider=request.provider.lower(),
+ modalities=request.modalities,
+ api_key=SecretStr(request.api_key) if request.api_key else None,
+ base_url=request.base_url,
+ endpoint=request.endpoint,
+ api_version=request.api_version,
+ endpoint_llm=request.endpoint_llm,
+ endpoint_embedding=request.endpoint_embedding,
+ endpoint_stt=request.endpoint_stt,
+ endpoint_tts=request.endpoint_tts,
+ project=request.project,
+ location=request.location,
+ credentials_path=request.credentials_path,
+ )
+ await cred.save()
+ return credential_to_response(cred, 0)
+
+ except Exception as e:
+ logger.error(f"Error creating credential: {e}")
+ raise HTTPException(status_code=500, detail="Failed to create credential")
+
+
+@router.get("/{credential_id}", response_model=CredentialResponse)
+async def get_credential(credential_id: str):
+ """Get a specific credential by ID. Never returns api_key."""
+ try:
+ cred = await Credential.get(credential_id)
+ models = await cred.get_linked_models()
+ return credential_to_response(cred, len(models))
+ except Exception as e:
+ logger.error(f"Error fetching credential {credential_id}: {e}")
+ raise HTTPException(status_code=404, detail="Credential not found")
+
+
+@router.put("/{credential_id}", response_model=CredentialResponse)
+async def update_credential(credential_id: str, request: UpdateCredentialRequest):
+ """Update an existing credential."""
+ try:
+ require_encryption_key()
+ except ValueError as e:
+ raise _handle_value_error(e)
+
+ # Validate all URL fields being updated
+ for url_field in [
+ request.base_url, request.endpoint, request.endpoint_llm,
+ request.endpoint_embedding, request.endpoint_stt, request.endpoint_tts,
+ ]:
+ if url_field:
+ try:
+ validate_url(url_field, "update")
+ except ValueError as e:
+ raise _handle_value_error(e)
+
+ try:
+ cred = await Credential.get(credential_id)
+
+ if request.name is not None:
+ cred.name = request.name
+ if request.modalities is not None:
+ cred.modalities = request.modalities
+ if request.api_key is not None:
+ cred.api_key = SecretStr(request.api_key)
+ if request.base_url is not None:
+ cred.base_url = request.base_url or None
+ if request.endpoint is not None:
+ cred.endpoint = request.endpoint or None
+ if request.api_version is not None:
+ cred.api_version = request.api_version or None
+ if request.endpoint_llm is not None:
+ cred.endpoint_llm = request.endpoint_llm or None
+ if request.endpoint_embedding is not None:
+ cred.endpoint_embedding = request.endpoint_embedding or None
+ if request.endpoint_stt is not None:
+ cred.endpoint_stt = request.endpoint_stt or None
+ if request.endpoint_tts is not None:
+ cred.endpoint_tts = request.endpoint_tts or None
+ if request.project is not None:
+ cred.project = request.project or None
+ if request.location is not None:
+ cred.location = request.location or None
+ if request.credentials_path is not None:
+ cred.credentials_path = request.credentials_path or None
+
+ await cred.save()
+ models = await cred.get_linked_models()
+ return credential_to_response(cred, len(models))
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error updating credential {credential_id}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to update credential")
+
+
+@router.delete("/{credential_id}", response_model=CredentialDeleteResponse)
+async def delete_credential(
+ credential_id: str,
+ delete_models: bool = Query(False, description="Also delete linked models"),
+ migrate_to: Optional[str] = Query(
+ None, description="Migrate linked models to this credential ID"
+ ),
+):
+ """
+ Delete a credential.
+
+ If the credential has linked models:
+ - Pass delete_models=true to delete them
+ - Pass migrate_to= to reassign them
+ - Without either, returns 409 with linked model info
+ """
+ try:
+ cred = await Credential.get(credential_id)
+ linked_models = await cred.get_linked_models()
+
+ if linked_models and not delete_models and not migrate_to:
+ raise HTTPException(
+ status_code=409,
+ detail={
+ "message": f"Credential has {len(linked_models)} linked model(s)",
+ "model_ids": [m.id for m in linked_models],
+ "model_names": [f"{m.provider}/{m.name}" for m in linked_models],
+ },
+ )
+
+ deleted_models = 0
+
+ if linked_models and migrate_to:
+ # Migrate models to another credential
+ target_cred = await Credential.get(migrate_to)
+ for model in linked_models:
+ model.credential = target_cred.id
+ await model.save()
+
+ elif linked_models and delete_models:
+ # Delete linked models
+ for model in linked_models:
+ await model.delete()
+ deleted_models += 1
+
+ # Delete the credential
+ await cred.delete()
+
+ return CredentialDeleteResponse(
+ message="Credential deleted successfully",
+ deleted_models=deleted_models,
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error deleting credential {credential_id}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to delete credential")
+
+
+# =============================================================================
+# Test / Discover / Register endpoints
+# =============================================================================
+
+
+@router.post("/{credential_id}/test")
+async def test_credential(credential_id: str):
+ """Test connection using this credential's configuration."""
+ return await svc_test_credential(credential_id)
+
+
+@router.post("/{credential_id}/discover", response_model=DiscoverModelsResponse)
+async def discover_models_for_credential(credential_id: str):
+ """Discover available models using this credential's API key."""
+ try:
+ cred = await Credential.get(credential_id)
+ config = cred.to_esperanto_config()
+ provider = cred.provider.lower()
+
+ discovered = await discover_with_config(provider, config)
+
+ return DiscoverModelsResponse(
+ credential_id=cred.id or "",
+ provider=provider,
+ discovered=[
+ DiscoveredModelResponse(
+ name=d["name"],
+ provider=d["provider"],
+ description=d.get("description"),
+ )
+ for d in discovered
+ ],
+ )
+
+ except Exception as e:
+ logger.error(f"Error discovering models for credential {credential_id}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to discover models")
+
+
+@router.post("/{credential_id}/register-models", response_model=RegisterModelsResponse)
+async def register_models_for_credential(
+ credential_id: str, request: RegisterModelsRequest
+):
+ """Register discovered models and link them to this credential."""
+ try:
+ result = await register_models(credential_id, request.models)
+ return RegisterModelsResponse(**result)
+ except Exception as e:
+ logger.error(f"Error registering models for credential {credential_id}: {e}")
+ raise HTTPException(status_code=500, detail="Failed to register models")
+
+
+# =============================================================================
+# Migration endpoints
+# =============================================================================
+
+
+@router.post("/migrate-from-provider-config")
+async def migrate_from_provider_config():
+ """Migrate existing ProviderConfig data to individual credential records."""
+ try:
+ return await svc_migrate_from_provider_config()
+ except ValueError as e:
+ raise _handle_value_error(e)
+ except Exception as e:
+ logger.error(f"ProviderConfig migration FAILED: {type(e).__name__}: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail="Migration from provider config failed")
+
+
+@router.post("/migrate-from-env")
+async def migrate_from_env():
+ """Migrate API keys from environment variables to credential records."""
+ try:
+ return await svc_migrate_from_env()
+ except ValueError as e:
+ raise _handle_value_error(e)
+ except Exception as e:
+ logger.error(f"Env migration FAILED: {type(e).__name__}: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail="Migration from environment variables failed")
diff --git a/api/routers/models.py b/api/routers/models.py
index 3744bf9..7a2ef9b 100644
--- a/api/routers/models.py
+++ b/api/routers/models.py
@@ -1,9 +1,11 @@
import os
-from typing import List, Optional
+import traceback
+from typing import Dict, List, Optional
from esperanto import AIFactory
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
+from pydantic import BaseModel
from api.models import (
DefaultModelsResponse,
@@ -11,25 +13,109 @@ from api.models import (
ModelResponse,
ProviderAvailabilityResponse,
)
+from open_notebook.domain.credential import Credential
+from open_notebook.ai.connection_tester import test_individual_model
+from open_notebook.ai.key_provider import provision_provider_keys
+from open_notebook.ai.model_discovery import (
+ discover_provider_models,
+ get_provider_model_count,
+ sync_all_providers,
+ sync_provider_models,
+)
from open_notebook.ai.models import DefaultModels, Model
from open_notebook.exceptions import InvalidInputError
router = APIRouter()
-def _check_openai_compatible_support(mode: str) -> bool:
- """
- Check if OpenAI-compatible provider is available for a specific mode.
+# =============================================================================
+# Model Discovery Response Models
+# =============================================================================
- Args:
- mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'
- Returns:
- bool: True if either generic or mode-specific env var is set
- """
- generic = os.environ.get("OPENAI_COMPATIBLE_BASE_URL") is not None
- specific = os.environ.get(f"OPENAI_COMPATIBLE_BASE_URL_{mode}") is not None
- return generic or specific
+class DiscoveredModelResponse(BaseModel):
+ """Response model for a discovered model."""
+
+ name: str
+ provider: str
+ model_type: str
+ description: Optional[str] = None
+
+
+class ProviderSyncResponse(BaseModel):
+ """Response model for provider sync operation."""
+
+ provider: str
+ discovered: int
+ new: int
+ existing: int
+
+
+class AllProvidersSyncResponse(BaseModel):
+ """Response model for syncing all providers."""
+
+ results: Dict[str, ProviderSyncResponse]
+ total_discovered: int
+ total_new: int
+
+
+class ProviderModelCountResponse(BaseModel):
+ """Response model for provider model counts."""
+
+ provider: str
+ counts: Dict[str, int]
+ total: int
+
+
+class AutoAssignResult(BaseModel):
+ """Response model for auto-assign operation."""
+
+ assigned: Dict[str, str] # slot_name -> model_id
+ skipped: List[str] # slots already assigned
+ missing: List[str] # slots with no available models
+
+
+class ModelTestResponse(BaseModel):
+ """Response model for individual model test."""
+
+ success: bool
+ message: str
+ details: Optional[str] = None
+
+
+# Provider priority for auto-assignment (higher priority first)
+PROVIDER_PRIORITY = [
+ "openai",
+ "anthropic",
+ "google",
+ "mistral",
+ "groq",
+ "deepseek",
+ "xai",
+ "openrouter",
+ "ollama",
+ "azure",
+ "openai_compatible",
+]
+
+# Model preference patterns (preferred models within each provider)
+MODEL_PREFERENCES = {
+ "openai": ["gpt-4o", "gpt-4", "gpt-3.5-turbo"],
+ "anthropic": ["claude-3-5-sonnet", "claude-3-opus", "claude-3-sonnet"],
+ "google": ["gemini-2.0", "gemini-1.5-pro", "gemini-pro"],
+ "mistral": ["mistral-large", "mixtral"],
+ "groq": ["llama-3.3", "llama-3.1", "mixtral"],
+}
+
+
+async def _check_provider_has_credential(provider: str) -> bool:
+ """Check if a provider has any credentials configured in the database."""
+ try:
+ credentials = await Credential.get_by_provider(provider)
+ return len(credentials) > 0
+ except Exception:
+ pass
+ return False
def _check_azure_support(mode: str) -> bool:
@@ -59,6 +145,23 @@ def _check_azure_support(mode: str) -> bool:
return generic or specific
+def _check_openai_compatible_support(mode: str) -> bool:
+ """
+ Check if OpenAI-compatible provider is available for a specific mode.
+
+ Args:
+ mode: One of 'LLM', 'EMBEDDING', 'STT', 'TTS'
+
+ Returns:
+ bool: True if either generic or mode-specific env var is set
+ """
+ generic = os.environ.get("OPENAI_COMPATIBLE_BASE_URL") is not None
+ specific = os.environ.get(f"OPENAI_COMPATIBLE_BASE_URL_{mode}") is not None
+ generic_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY") is not None
+ specific_key = os.environ.get(f"OPENAI_COMPATIBLE_API_KEY_{mode}") is not None
+ return generic or specific or generic_key or specific_key
+
+
@router.get("/models", response_model=List[ModelResponse])
async def get_models(
type: Optional[str] = Query(None, description="Filter by model type"),
@@ -76,6 +179,7 @@ async def get_models(
name=model.name,
provider=model.provider,
type=model.type,
+ credential=model.credential,
created=str(model.created),
updated=str(model.updated),
)
@@ -119,6 +223,7 @@ async def create_model(model_data: ModelCreate):
name=model_data.name,
provider=model_data.provider,
type=model_data.type,
+ credential=model_data.credential,
)
await new_model.save()
@@ -127,6 +232,7 @@ async def create_model(model_data: ModelCreate):
name=new_model.name,
provider=new_model.provider,
type=new_model.type,
+ credential=new_model.credential,
created=str(new_model.created),
updated=str(new_model.updated),
)
@@ -157,6 +263,29 @@ async def delete_model(model_id: str):
raise HTTPException(status_code=500, detail=f"Error deleting model: {str(e)}")
+@router.post("/models/{model_id}/test", response_model=ModelTestResponse)
+async def test_model(model_id: str):
+ """Test if a specific model is correctly configured and functional."""
+ try:
+ model = await Model.get(model_id)
+ if not model:
+ raise HTTPException(status_code=404, detail="Model not found")
+ except HTTPException:
+ raise
+ except Exception:
+ raise HTTPException(status_code=404, detail="Model not found")
+
+ try:
+ success, message = await test_individual_model(model)
+ return ModelTestResponse(success=success, message=message)
+ except Exception as e:
+ logger.error(f"Error testing model {model_id}: {traceback.format_exc()}")
+ return ModelTestResponse(
+ success=False,
+ message=str(e)[:200],
+ )
+
+
@router.get("/models/defaults", response_model=DefaultModelsResponse)
async def get_default_models():
"""Get default model assignments."""
@@ -231,43 +360,62 @@ async def update_default_models(defaults_data: DefaultModelsResponse):
@router.get("/models/providers", response_model=ProviderAvailabilityResponse)
async def get_provider_availability():
- """Get provider availability based on environment variables."""
+ """Get provider availability based on database config and environment variables."""
try:
- # Check which providers have API keys configured
- provider_status = {
- "ollama": os.environ.get("OLLAMA_API_BASE") is not None,
- "openai": os.environ.get("OPENAI_API_KEY") is not None,
- "groq": os.environ.get("GROQ_API_KEY") is not None,
- "xai": os.environ.get("XAI_API_KEY") is not None,
- "vertex": (
- os.environ.get("VERTEX_PROJECT") is not None
- and os.environ.get("VERTEX_LOCATION") is not None
- and os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") is not None
- ),
- "google": (
- os.environ.get("GOOGLE_API_KEY") is not None
- or os.environ.get("GEMINI_API_KEY") is not None
- ),
- "openrouter": os.environ.get("OPENROUTER_API_KEY") is not None,
- "anthropic": os.environ.get("ANTHROPIC_API_KEY") is not None,
- "elevenlabs": os.environ.get("ELEVENLABS_API_KEY") is not None,
- "voyage": os.environ.get("VOYAGE_API_KEY") is not None,
- "azure": (
- _check_azure_support("LLM")
- or _check_azure_support("EMBEDDING")
- or _check_azure_support("STT")
- or _check_azure_support("TTS")
- ),
- "mistral": os.environ.get("MISTRAL_API_KEY") is not None,
- "deepseek": os.environ.get("DEEPSEEK_API_KEY") is not None,
- "openai-compatible": (
- _check_openai_compatible_support("LLM")
- or _check_openai_compatible_support("EMBEDDING")
- or _check_openai_compatible_support("STT")
- or _check_openai_compatible_support("TTS")
- ),
+ # Check which providers have credentials in the database or env vars
+ # For each provider, check DB credentials first, then env vars as fallback
+
+ # Simple env var mapping for backward compatibility
+ env_var_map = {
+ "openai": "OPENAI_API_KEY",
+ "anthropic": "ANTHROPIC_API_KEY",
+ "google": "GOOGLE_API_KEY",
+ "groq": "GROQ_API_KEY",
+ "mistral": "MISTRAL_API_KEY",
+ "deepseek": "DEEPSEEK_API_KEY",
+ "xai": "XAI_API_KEY",
+ "openrouter": "OPENROUTER_API_KEY",
+ "voyage": "VOYAGE_API_KEY",
+ "elevenlabs": "ELEVENLABS_API_KEY",
+ "ollama": "OLLAMA_API_BASE",
}
+ provider_status = {}
+
+ # Check simple providers: credential in DB or env var
+ for provider, env_var in env_var_map.items():
+ has_cred = await _check_provider_has_credential(provider)
+ has_env = os.environ.get(env_var) is not None
+ provider_status[provider] = has_cred or has_env
+
+ # Google also supports GEMINI_API_KEY
+ if not provider_status.get("google"):
+ provider_status["google"] = os.environ.get("GEMINI_API_KEY") is not None
+
+ # Vertex: DB credential or env vars
+ provider_status["vertex"] = (
+ await _check_provider_has_credential("vertex")
+ or os.environ.get("VERTEX_PROJECT") is not None
+ )
+
+ # Azure: DB credential or env vars
+ provider_status["azure"] = (
+ await _check_provider_has_credential("azure")
+ or _check_azure_support("LLM")
+ or _check_azure_support("EMBEDDING")
+ or _check_azure_support("STT")
+ or _check_azure_support("TTS")
+ )
+
+ # OpenAI-compatible: DB credential or env vars
+ provider_status["openai-compatible"] = (
+ await _check_provider_has_credential("openai_compatible")
+ or _check_openai_compatible_support("LLM")
+ or _check_openai_compatible_support("EMBEDDING")
+ or _check_openai_compatible_support("STT")
+ or _check_openai_compatible_support("TTS")
+ )
+
available_providers = [k for k, v in provider_status.items() if v]
unavailable_providers = [k for k, v in provider_status.items() if not v]
@@ -289,21 +437,23 @@ async def get_provider_availability():
# Special handling for openai-compatible to check mode-specific availability
if provider == "openai-compatible":
+ has_db_cred = await _check_provider_has_credential("openai_compatible")
for model_type, mode in mode_mapping.items():
if (
model_type in esperanto_available
and provider in esperanto_available[model_type]
):
- if _check_openai_compatible_support(mode):
+ if has_db_cred or _check_openai_compatible_support(mode):
supported_types[provider].append(model_type)
# Special handling for azure to check mode-specific availability
elif provider == "azure":
+ has_db_cred = await _check_provider_has_credential("azure")
for model_type, mode in mode_mapping.items():
if (
model_type in esperanto_available
and provider in esperanto_available[model_type]
):
- if _check_azure_support(mode):
+ if has_db_cred or _check_azure_support(mode):
supported_types[provider].append(model_type)
else:
# Standard provider detection
@@ -321,3 +471,300 @@ async def get_provider_availability():
raise HTTPException(
status_code=500, detail=f"Error checking provider availability: {str(e)}"
)
+
+
+# =============================================================================
+# Model Discovery Endpoints
+# =============================================================================
+
+
+@router.get(
+ "/models/discover/{provider}", response_model=List[DiscoveredModelResponse]
+)
+async def discover_models(provider: str):
+ """
+ Discover available models from a provider without registering them.
+
+ This endpoint queries the provider's API to list available models
+ but does not save them to the database. Use the sync endpoint
+ to both discover and register models.
+ """
+ try:
+ # Provision DB-stored credentials into env vars before discovery
+ await provision_provider_keys(provider)
+ discovered = await discover_provider_models(provider)
+ return [
+ DiscoveredModelResponse(
+ name=m.name,
+ provider=m.provider,
+ model_type=m.model_type,
+ description=m.description,
+ )
+ for m in discovered
+ ]
+ except Exception as e:
+ logger.error(f"Error discovering models for {provider}: {str(e)}")
+ raise HTTPException(
+ status_code=500, detail="Error discovering models. Check server logs for details."
+ )
+
+
+@router.post("/models/sync/{provider}", response_model=ProviderSyncResponse)
+async def sync_models(provider: str):
+ """
+ Sync models for a specific provider.
+
+ Discovers available models from the provider's API and registers
+ any new models in the database. Existing models are skipped.
+
+ Returns counts of discovered, new, and existing models.
+ """
+ try:
+ # Provision DB-stored credentials into env vars before discovery
+ await provision_provider_keys(provider)
+ discovered, new, existing = await sync_provider_models(
+ provider, auto_register=True
+ )
+ return ProviderSyncResponse(
+ provider=provider,
+ discovered=discovered,
+ new=new,
+ existing=existing,
+ )
+ except Exception as e:
+ logger.error(f"Error syncing models for {provider}: {str(e)}")
+ raise HTTPException(status_code=500, detail="Error syncing models. Check server logs for details.")
+
+
+@router.post("/models/sync", response_model=AllProvidersSyncResponse)
+async def sync_all_models():
+ """
+ Sync models for all configured providers.
+
+ Discovers and registers models from all providers that have
+ valid API keys configured. This is useful for initial setup
+ or periodic refresh of available models.
+ """
+ try:
+ results = await sync_all_providers()
+
+ response_results = {}
+ total_discovered = 0
+ total_new = 0
+
+ for provider, (discovered, new, existing) in results.items():
+ response_results[provider] = ProviderSyncResponse(
+ provider=provider,
+ discovered=discovered,
+ new=new,
+ existing=existing,
+ )
+ total_discovered += discovered
+ total_new += new
+
+ return AllProvidersSyncResponse(
+ results=response_results,
+ total_discovered=total_discovered,
+ total_new=total_new,
+ )
+ except Exception as e:
+ logger.error(f"Error syncing all models: {str(e)}")
+ raise HTTPException(
+ status_code=500, detail=f"Error syncing all models: {str(e)}"
+ )
+
+
+@router.get("/models/count/{provider}", response_model=ProviderModelCountResponse)
+async def get_model_count(provider: str):
+ """
+ Get count of registered models for a provider, grouped by type.
+
+ Returns counts for each model type (language, embedding,
+ speech_to_text, text_to_speech) as well as total count.
+ """
+ try:
+ counts = await get_provider_model_count(provider)
+ total = sum(counts.values())
+ return ProviderModelCountResponse(
+ provider=provider,
+ counts=counts,
+ total=total,
+ )
+ except Exception as e:
+ logger.error(f"Error getting model count for {provider}: {str(e)}")
+ raise HTTPException(
+ status_code=500, detail=f"Error getting model count: {str(e)}"
+ )
+
+
+@router.get("/models/by-provider/{provider}", response_model=List[ModelResponse])
+async def get_models_by_provider(provider: str):
+ """
+ Get all registered models for a specific provider.
+
+ Returns models from the database that belong to the specified provider.
+ """
+ try:
+ from open_notebook.database.repository import repo_query
+
+ models = await repo_query(
+ "SELECT * FROM model WHERE provider = $provider ORDER BY type, name",
+ {"provider": provider},
+ )
+
+ return [
+ ModelResponse(
+ id=model.get("id", ""),
+ name=model.get("name", ""),
+ provider=model.get("provider", ""),
+ type=model.get("type", ""),
+ credential=model.get("credential"),
+ created=str(model.get("created", "")),
+ updated=str(model.get("updated", "")),
+ )
+ for model in models
+ ]
+ except Exception as e:
+ logger.error(f"Error fetching models for {provider}: {str(e)}")
+ raise HTTPException(
+ status_code=500, detail=f"Error fetching models: {str(e)}"
+ )
+
+
+def _get_preferred_model(
+ models: List[Dict], provider_priority: List[str], model_preferences: Dict
+) -> Optional[Dict]:
+ """
+ Select the best model from a list based on provider priority and model preferences.
+
+ Args:
+ models: List of model dictionaries with 'provider', 'name', 'id' keys
+ provider_priority: List of providers in preference order
+ model_preferences: Dict mapping provider to list of preferred model name patterns
+
+ Returns:
+ The best model dict, or None if no models available
+ """
+ if not models:
+ return None
+
+ # Group models by provider
+ by_provider: Dict[str, List[Dict]] = {}
+ for model in models:
+ provider = model.get("provider", "")
+ if provider not in by_provider:
+ by_provider[provider] = []
+ by_provider[provider].append(model)
+
+ # Find first provider with models (in priority order)
+ for provider in provider_priority:
+ if provider in by_provider:
+ provider_models = by_provider[provider]
+
+ # Check for preferred models within this provider
+ if provider in model_preferences:
+ for preference in model_preferences[provider]:
+ for model in provider_models:
+ if preference.lower() in model.get("name", "").lower():
+ return model
+
+ # Fall back to first model from this provider
+ return provider_models[0]
+
+ # Fall back to first model from any provider
+ return models[0] if models else None
+
+
+@router.post("/models/auto-assign", response_model=AutoAssignResult)
+async def auto_assign_defaults():
+ """
+ Auto-assign default models based on available models.
+
+ This endpoint intelligently assigns the first available model of each
+ required type to the corresponding default slot. It uses provider
+ priority (preferring premium providers like OpenAI, Anthropic) and
+ model preferences within each provider.
+
+ Returns:
+ - assigned: Dict of slot names to assigned model IDs
+ - skipped: List of slots that already have models assigned
+ - missing: List of slots with no available models
+ """
+ try:
+ from open_notebook.database.repository import repo_query
+
+ # Get current defaults
+ defaults = await DefaultModels.get_instance()
+
+ # Get all models grouped by type
+ all_models = await repo_query(
+ "SELECT * FROM model ORDER BY provider, name",
+ {},
+ )
+
+ # Group models by type
+ models_by_type: Dict[str, List[Dict]] = {
+ "language": [],
+ "embedding": [],
+ "text_to_speech": [],
+ "speech_to_text": [],
+ }
+
+ for model in all_models:
+ model_type = model.get("type", "")
+ if model_type in models_by_type:
+ models_by_type[model_type].append(model)
+
+ # Define slot configuration: (slot_name, model_type, current_value)
+ slot_configs = [
+ ("default_chat_model", "language", defaults.default_chat_model), # type: ignore[attr-defined]
+ ("default_transformation_model", "language", defaults.default_transformation_model), # type: ignore[attr-defined]
+ ("default_tools_model", "language", defaults.default_tools_model), # type: ignore[attr-defined]
+ ("large_context_model", "language", defaults.large_context_model), # type: ignore[attr-defined]
+ ("default_embedding_model", "embedding", defaults.default_embedding_model), # type: ignore[attr-defined]
+ ("default_text_to_speech_model", "text_to_speech", defaults.default_text_to_speech_model), # type: ignore[attr-defined]
+ ("default_speech_to_text_model", "speech_to_text", defaults.default_speech_to_text_model), # type: ignore[attr-defined]
+ ]
+
+ assigned: Dict[str, str] = {}
+ skipped: List[str] = []
+ missing: List[str] = []
+
+ for slot_name, model_type, current_value in slot_configs:
+ if current_value:
+ # Slot already has a value
+ skipped.append(slot_name)
+ continue
+
+ available_models = models_by_type.get(model_type, [])
+ if not available_models:
+ # No models of this type available
+ missing.append(slot_name)
+ continue
+
+ # Select best model for this slot
+ best_model = _get_preferred_model(
+ available_models, PROVIDER_PRIORITY, MODEL_PREFERENCES
+ )
+
+ if best_model:
+ model_id = best_model.get("id", "")
+ assigned[slot_name] = model_id
+ # Update the defaults object
+ setattr(defaults, slot_name, model_id)
+
+ # Save updated defaults if any assignments were made
+ if assigned:
+ await defaults.update()
+
+ return AutoAssignResult(
+ assigned=assigned,
+ skipped=skipped,
+ missing=missing,
+ )
+
+ except Exception as e:
+ logger.error(f"Error auto-assigning defaults: {str(e)}")
+ raise HTTPException(
+ status_code=500, detail=f"Error auto-assigning defaults: {str(e)}"
+ )
diff --git a/docker-compose.full.yml b/docker-compose.full.yml
deleted file mode 100644
index 96d4e09..0000000
--- a/docker-compose.full.yml
+++ /dev/null
@@ -1,28 +0,0 @@
-services:
- surrealdb:
- image: surrealdb/surrealdb:v2
- volumes:
- - ./surreal_data:/mydata
- environment:
- - SURREAL_EXPERIMENTAL_GRAPHQL=true
- ports:
- - "8000:8000"
- command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
- pull_policy: always
- user: root
- restart: always
- open_notebook:
- image: lfnovo/open_notebook:v1-latest
- # build:
- # context: .
- # dockerfile: Dockerfile
- ports:
- - "8502:8502"
- - "5055:5055"
- env_file:
- - ./docker.env
- depends_on:
- - surrealdb
- volumes:
- - ./notebook_data:/app/data
- restart: always
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..47c59ff
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,36 @@
+services:
+ surrealdb:
+ image: surrealdb/surrealdb:v2
+ command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
+ user: root # Required for bind mounts on Linux
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./surreal_data:/mydata
+ environment:
+ - SURREAL_EXPERIMENTAL_GRAPHQL=true
+ restart: always
+ pull_policy: always
+
+ open_notebook:
+ image: lfnovo/open_notebook:v1-latest
+ ports:
+ - "8502:8502" # Web UI
+ - "5055:5055" # REST API
+ environment:
+ # REQUIRED: Change this to your own secret string
+ # This encrypts your API keys in the database
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
+
+ # Database connection (default values - no need to change)
+ - SURREAL_URL=ws://surrealdb:8000/rpc
+ - SURREAL_USER=root
+ - SURREAL_PASSWORD=root
+ - SURREAL_NAMESPACE=open_notebook
+ - SURREAL_DATABASE=open_notebook
+ volumes:
+ - ./notebook_data:/app/data
+ depends_on:
+ - surrealdb
+ restart: always
+ pull_policy: always
diff --git a/docs/0-START-HERE/quick-start-cloud.md b/docs/0-START-HERE/quick-start-cloud.md
index de47392..5c68198 100644
--- a/docs/0-START-HERE/quick-start-cloud.md
+++ b/docs/0-START-HERE/quick-start-cloud.md
@@ -39,22 +39,8 @@ services:
- "8502:8502" # Web UI
- "5055:5055" # API
environment:
- # Choose ONE provider (uncomment your choice):
-
- # OpenRouter - 100+ models with one API key
- - OPENROUTER_API_KEY=sk-or-...
-
- # Anthropic (Claude) - Excellent reasoning
- # - ANTHROPIC_API_KEY=sk-ant-...
-
- # Google (Gemini) - Large context, cost-effective
- # - GOOGLE_API_KEY=...
-
- # Groq - Ultra-fast inference, free tier available
- # - GROQ_API_KEY=gsk_...
-
- # Mistral - European provider, good quality
- # - MISTRAL_API_KEY=...
+ # Encryption key for credential storage (required)
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database (required)
- SURREAL_URL=ws://surrealdb:8000/rpc
@@ -71,8 +57,7 @@ services:
```
**Edit the file:**
-- Uncomment ONE provider and add your API key
-- Comment out or remove the others
+- Replace `change-me-to-a-secret-string` with your own secret (any string works)
---
@@ -99,7 +84,23 @@ You should see the Open Notebook interface!
---
-## Step 4: Configure Your Model (1 min)
+## Step 4: Configure Your AI Provider (1 min)
+
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select your provider (e.g., Anthropic, Google, Groq, OpenRouter)
+4. Give it a name, paste your API key
+5. Click **Save**
+6. Click **Test Connection** — should show success
+7. Click **Discover Models** → **Register Models**
+
+Your provider's models are now available!
+
+> **Multiple providers**: You can add credentials for as many providers as you want. Just repeat this step for each provider.
+
+---
+
+## Step 5: Configure Your Model (1 min)
1. Go to **Settings** (gear icon)
2. Navigate to **Models**
@@ -117,7 +118,7 @@ You should see the Open Notebook interface!
---
-## Step 5: Create Your First Notebook (1 min)
+## Step 6: Create Your First Notebook (1 min)
1. Click **New Notebook**
2. Name: "My Research"
@@ -125,7 +126,7 @@ You should see the Open Notebook interface!
---
-## Step 6: Add Content & Chat (2 min)
+## Step 7: Add Content & Chat (2 min)
1. Click **Add Source**
2. Choose **Web Link**
@@ -139,7 +140,8 @@ You should see the Open Notebook interface!
- [ ] Docker is running
- [ ] You can access `http://localhost:8502`
-- [ ] Models are configured for your provider
+- [ ] Provider credential is configured and tested
+- [ ] Models are registered
- [ ] You created a notebook
- [ ] Chat works
@@ -160,29 +162,14 @@ You should see the Open Notebook interface!
---
-## Using Multiple Providers
-
-You can enable multiple providers simultaneously:
-
-```yaml
-environment:
- - OPENROUTER_API_KEY=sk-or-...
- - ANTHROPIC_API_KEY=sk-ant-...
- - GOOGLE_API_KEY=...
- - GROQ_API_KEY=gsk_...
-```
-
-Then switch between them in **Settings** > **Models** as needed.
-
----
-
## Troubleshooting
### "Model not found" Error
-1. Verify your API key is correct (no extra spaces)
-2. Check you have credits/access for the model
-3. Restart: `docker compose restart api`
+1. Go to **Settings** → **API Keys**
+2. Click **Test Connection** on your credential
+3. If valid, click **Discover Models** → **Register Models**
+4. Check you have credits/access for the model
### "Cannot connect to server"
diff --git a/docs/0-START-HERE/quick-start-local.md b/docs/0-START-HERE/quick-start-local.md
index 59f9e25..646c60c 100644
--- a/docs/0-START-HERE/quick-start-local.md
+++ b/docs/0-START-HERE/quick-start-local.md
@@ -14,10 +14,10 @@ Get Open Notebook running with **100% local AI** using Ollama. No cloud API keys
## Step 1: Choose Your Setup (1 min)
-### 🏠 Local Machine (Same Computer)
+### Local Machine (Same Computer)
Everything runs on your machine. Recommended for testing/learning.
-### 🌐 Remote Server (Raspberry Pi, NAS, Cloud VM)
+### Remote Server (Raspberry Pi, NAS, Cloud VM)
Run on a different computer, access from another. Needs network configuration.
---
@@ -44,8 +44,8 @@ services:
- "8502:8502" # Web UI (React frontend)
- "5055:5055" # API (required!)
environment:
- # NO API KEYS NEEDED - Using Ollama (free, local)
- - OLLAMA_API_BASE=http://ollama:11434
+ # Encryption key for credential storage (required)
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database (required)
- SURREAL_URL=ws://surrealdb:8000/rpc
@@ -73,7 +73,8 @@ services:
```
-**That's it!** No API keys, no secrets, completely private.
+**Edit the file:**
+- Replace `change-me-to-a-secret-string` with your own secret (any string works)
---
@@ -119,9 +120,22 @@ You should see the Open Notebook interface.
---
-## Step 6: Configure Local Model (1 min)
+## Step 6: Configure Ollama Provider (1 min)
-1. Click **Settings** (top right) → **Models**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **Ollama**
+4. Give it a name (e.g., "Local Ollama")
+5. Enter the base URL: `http://ollama:11434`
+6. Click **Save**
+7. Click **Test Connection** — should show success
+8. Click **Discover Models** → **Register Models**
+
+---
+
+## Step 7: Configure Local Model (1 min)
+
+1. Go to **Settings** → **Models**
2. Set:
- **Language Model**: `ollama/mistral` (or whichever model you downloaded)
- **Embedding Model**: `ollama/nomic-embed-text` (auto-downloads if missing)
@@ -129,7 +143,7 @@ You should see the Open Notebook interface.
---
-## Step 7: Create Your First Notebook (1 min)
+## Step 8: Create Your First Notebook (1 min)
1. Click **New Notebook**
2. Name: "My Private Research"
@@ -137,7 +151,7 @@ You should see the Open Notebook interface.
---
-## Step 8: Add Local Content (1 min)
+## Step 9: Add Local Content (1 min)
1. Click **Add Source**
2. Choose **Text**
@@ -146,7 +160,7 @@ You should see the Open Notebook interface.
---
-## Step 9: Chat With Your Content (1 min)
+## Step 10: Chat With Your Content (1 min)
1. Go to **Chat**
2. Type: "What did you learn from this?"
@@ -159,20 +173,21 @@ You should see the Open Notebook interface.
- [ ] Docker is running
- [ ] You can access `http://localhost:8502`
-- [ ] Models are configured
+- [ ] Ollama credential is configured and tested
+- [ ] Models are registered
- [ ] You created a notebook
- [ ] Chat works with local model
-**All checked?** 🎉 You have a completely **private, offline** research assistant!
+**All checked?** You have a completely **private, offline** research assistant!
---
## Advantages of Local Setup
-✅ **No API costs** - Free forever
-✅ **No internet required** - True offline capability
-✅ **Privacy first** - Your data never leaves your machine
-✅ **No subscriptions** - No monthly bills
+- **No API costs** - Free forever
+- **No internet required** - True offline capability
+- **Privacy first** - Your data never leaves your machine
+- **No subscriptions** - No monthly bills
**Trade-off:** Slower than cloud models (depends on your CPU/GPU)
@@ -248,13 +263,12 @@ docker exec open_notebook-ollama-1 ollama pull neural-chat
1. Download LM Studio: https://lmstudio.ai
2. Open the app, download a model from the library
3. Go to "Local Server" tab, start server (port 1234)
-4. Update your docker-compose.yml:
- ```yaml
- environment:
- - OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:1234/v1
- - OPENAI_COMPATIBLE_API_KEY=not-needed
- ```
-5. Configure in Settings → Models → Select your LM Studio model
+4. In Open Notebook, go to **Settings** → **API Keys**
+5. Click **Add Credential** → Select **OpenAI-Compatible**
+6. Enter base URL: `http://host.docker.internal:1234/v1`
+7. Enter API key: `lm-studio` (placeholder)
+8. Click **Save**, then **Test Connection**
+9. Configure in Settings → Models → Select your LM Studio model
**Note**: LM Studio runs outside Docker, use `host.docker.internal` to connect.
@@ -264,10 +278,10 @@ docker exec open_notebook-ollama-1 ollama pull neural-chat
- **Switch models**: Change in Settings → Models anytime
- **Add more models**:
- - Ollama: Run `ollama pull `
+ - Ollama: Run `ollama pull `, then re-discover models from the credential
- LM Studio: Download from the app library
- **Deploy to server**: Same docker-compose.yml works anywhere
-- **Use cloud hybrid**: Keep some local models, add OpenAI/Anthropic for complex tasks
+- **Use cloud hybrid**: Keep some local models, add cloud provider credentials for complex tasks
---
diff --git a/docs/0-START-HERE/quick-start-openai.md b/docs/0-START-HERE/quick-start-openai.md
index 92f7470..56968c6 100644
--- a/docs/0-START-HERE/quick-start-openai.md
+++ b/docs/0-START-HERE/quick-start-openai.md
@@ -36,8 +36,8 @@ services:
- "8502:8502" # Web UI
- "5055:5055" # API
environment:
- # Your OpenAI key
- - OPENAI_API_KEY=sk-...
+ # Encryption key for credential storage (required)
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
# Database (required)
- SURREAL_URL=ws://surrealdb:8000/rpc
@@ -54,7 +54,7 @@ services:
```
**Edit the file:**
-- Replace `sk-...` with your actual OpenAI API key
+- Replace `change-me-to-a-secret-string` with your own secret (any string works)
---
@@ -81,7 +81,22 @@ You should see the Open Notebook interface!
---
-## Step 4: Create Your First Notebook (1 min)
+## Step 4: Configure Your OpenAI Provider (1 min)
+
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **OpenAI**
+4. Give it a name (e.g., "My OpenAI Key")
+5. Paste your OpenAI API key
+6. Click **Save**
+7. Click **Test Connection** — should show success
+8. Click **Discover Models** → **Register Models**
+
+Your OpenAI models are now available!
+
+---
+
+## Step 5: Create Your First Notebook (1 min)
1. Click **New Notebook**
2. Name: "My Research"
@@ -89,7 +104,7 @@ You should see the Open Notebook interface!
---
-## Step 5: Add a Source (1 min)
+## Step 6: Add a Source (1 min)
1. Click **Add Source**
2. Choose **Web Link**
@@ -99,7 +114,7 @@ You should see the Open Notebook interface!
---
-## Step 6: Chat With Your Content (1 min)
+## Step 7: Chat With Your Content (1 min)
1. Go to **Chat**
2. Type: "What is artificial intelligence?"
@@ -112,11 +127,12 @@ You should see the Open Notebook interface!
- [ ] Docker is running
- [ ] You can access `http://localhost:8502`
+- [ ] OpenAI credential is configured and tested
- [ ] You created a notebook
- [ ] You added a source
- [ ] Chat works
-**All checked?** 🎉 You have a fully working AI research assistant!
+**All checked?** You have a fully working AI research assistant!
---
@@ -142,9 +158,10 @@ Then access at `http://localhost:8503`
### "API key not working"
-1. Double-check your API key (no extra spaces)
-2. Verify you added credits at https://platform.openai.com
-3. Restart: `docker compose restart api`
+1. Go to **Settings** → **API Keys**
+2. Click **Test Connection** on your OpenAI credential
+3. If it fails, verify your key at https://platform.openai.com
+4. Delete the credential and create a new one with the correct key
### "Cannot connect to server"
diff --git a/docs/1-INSTALLATION/docker-compose.md b/docs/1-INSTALLATION/docker-compose.md
index 695aee2..93e4764 100644
--- a/docs/1-INSTALLATION/docker-compose.md
+++ b/docs/1-INSTALLATION/docker-compose.md
@@ -10,55 +10,51 @@ Multi-container setup with separate services. **Best for most users.**
- **5-10 minutes** of your time
- **API key** for at least one AI provider (OpenAI recommended for beginners)
-## Step 1: Get an API Key (2 min)
+## Step 1: Get docker-compose.yml (1 min)
-Choose at least one AI provider. **OpenAI recommended if you're unsure:**
-
-```
-OpenAI: https://platform.openai.com/api-keys
-Anthropic: https://console.anthropic.com/
-Google: https://aistudio.google.com/
-Groq: https://console.groq.com/
+**Option A: Download from repository**
+```bash
+curl -o docker-compose.yml https://raw.githubusercontent.com/lfnovo/open-notebook/main/docker-compose.yml
```
-Add at least $5 in credits to your account.
+**Option B: Use the official file from the repo**
-(Skip this if using Ollama for free local models)
+The official `docker-compose.yml` is in the root of our repository: [View on GitHub](https://github.com/lfnovo/open-notebook/blob/main/docker-compose.yml)
----
+Copy that file to your project folder.
-## Step 2: Create Configuration (2 min)
+**Option C: Create manually**
-Create a folder `open-notebook` and add this file:
+Create a file called `docker-compose.yml` with this content:
-**docker-compose.yml**:
```yaml
services:
surrealdb:
image: surrealdb/surrealdb:v2
- command: start --user root --pass password --bind 0.0.0.0:8000 rocksdb:/mydata/mydatabase.db
- user: root # Required for bind mounts on Linux (SurrealDB runs as non-root by default)
+ command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
+ user: root # Required for bind mounts on Linux
ports:
- "8000:8000"
volumes:
- ./surreal_data:/mydata
+ environment:
+ - SURREAL_EXPERIMENTAL_GRAPHQL=true
+ restart: always
+ pull_policy: always
open_notebook:
image: lfnovo/open_notebook:v1-latest
- pull_policy: always
ports:
- "8502:8502" # Web UI
- - "5055:5055" # API
+ - "5055:5055" # REST API
environment:
- # AI Provider (choose ONE)
- - OPENAI_API_KEY=sk-... # Your OpenAI key
- # - ANTHROPIC_API_KEY=sk-ant-... # Or Anthropic
- # - GOOGLE_API_KEY=... # Or Google
+ # REQUIRED: Change this to your own secret string
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
- # Database
+ # Database connection (default values - no need to change)
- SURREAL_URL=ws://surrealdb:8000/rpc
- SURREAL_USER=root
- - SURREAL_PASSWORD=password
+ - SURREAL_PASSWORD=root
- SURREAL_NAMESPACE=open_notebook
- SURREAL_DATABASE=open_notebook
volumes:
@@ -66,17 +62,15 @@ services:
depends_on:
- surrealdb
restart: always
-
+ pull_policy: always
```
**Edit the file:**
-- Replace `sk-...` with your actual OpenAI API key
-- (Or use Anthropic, Google, Groq keys instead)
-- If you have multiple keys, uncomment the ones you want
+- Replace `change-me-to-a-secret-string` with your own secret (any string works, e.g., `my-super-secret-key-123`)
---
-## Step 3: Start Services (2 min)
+## Step 2: Start Services (2 min)
Open terminal in the `open-notebook` folder:
@@ -97,7 +91,7 @@ docker compose ps
---
-## Step 4: Verify Installation (1 min)
+## Step 3: Verify Installation (1 min)
**API Health:**
```bash
@@ -115,6 +109,26 @@ You should see the Open Notebook interface!
---
+## Step 4: Configure AI Provider (2 min)
+
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select your provider (e.g., OpenAI, Anthropic, Google)
+4. Give it a name, paste your API key
+5. Click **Save**
+6. Click **Test Connection** — should show success
+7. Click **Discover Models** → **Register Models**
+
+Your models are now available!
+
+> **Need an API key?** Get one from your chosen provider:
+> - **OpenAI**: https://platform.openai.com/api-keys
+> - **Anthropic**: https://console.anthropic.com/
+> - **Google**: https://aistudio.google.com/
+> - **Groq**: https://console.groq.com/
+
+---
+
## Step 5: First Notebook (2 min)
1. Click **New Notebook**
@@ -122,35 +136,27 @@ You should see the Open Notebook interface!
3. Description: "Getting started"
4. Click **Create**
-Done! You now have a fully working Open Notebook instance. 🎉
+Done! You now have a fully working Open Notebook instance.
---
## Configuration
-### Using Different AI Providers
+### Adding Ollama (Free Local Models)
-Change `environment` section in `docker-compose.yml`:
+Instead of manually editing, use our ready-made example:
-```yaml
-# For Anthropic (Claude)
-- ANTHROPIC_API_KEY=sk-ant-...
+```bash
+# Download the Ollama example
+curl -o docker-compose.yml https://raw.githubusercontent.com/lfnovo/open-notebook/main/examples/docker-compose-ollama.yml
-# For Google Gemini
-- GOOGLE_API_KEY=...
-
-# For Groq (fast, free tier available)
-- GROQ_API_KEY=...
-
-# For local Ollama docker container (free, offline) --> Virtual machine
-- OLLAMA_API_BASE=http://ollama:11434
-# For localhost Ollama (free, offline) --> Real machine
-# - OLLAMA_API_BASE=http://host.docker.internal:11434
+# Or copy from repo
+cp examples/docker-compose-ollama.yml docker-compose.yml
```
-### Adding Ollama container (Free Local Models)
+See [examples/docker-compose-ollama.yml](../../examples/docker-compose-ollama.yml) for the complete setup.
-Add to `docker-compose.yml`:
+**Manual setup:** Add this to your existing `docker-compose.yml`:
```yaml
ollama:
@@ -162,35 +168,35 @@ Add to `docker-compose.yml`:
restart: always
volumes:
- surreal_data:
ollama_models:
```
-Then update API service:
-```yaml
-environment:
- - OLLAMA_API_BASE=http://ollama:11434
-```
-
-Restart and pull a model:
+Then restart and pull a model:
```bash
docker compose restart
docker exec open_notebook-ollama-1 ollama pull mistral
```
+Configure Ollama in the Settings UI:
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential** → Select **Ollama**
+3. Enter base URL: `http://ollama:11434`
+4. Click **Save**, then **Test Connection**
+5. Click **Discover Models** → **Register Models**
+
---
## Environment Variables Reference
| Variable | Purpose | Example |
|----------|---------|---------|
-| `OPENAI_API_KEY` | OpenAI API key | `sk-proj-...` |
-| `ANTHROPIC_API_KEY` | Anthropic/Claude key | `sk-ant-...` |
+| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | Encryption key for credentials | `my-secret-key` |
| `SURREAL_URL` | Database connection | `ws://surrealdb:8000/rpc` |
| `SURREAL_USER` | Database user | `root` |
-| `SURREAL_PASSWORD` | Database password | `password` |
+| `SURREAL_PASSWORD` | Database password | `root` |
| `API_URL` | API external URL | `http://localhost:5055` |
-| `NEXT_PUBLIC_API_URL` | Frontend API URL | `http://localhost:5055` |
+
+See [Environment Reference](../5-CONFIGURATION/environment-reference.md) for complete list.
---
@@ -266,12 +272,13 @@ Then access at `http://localhost:8503`
---
-### API Key Not Working
+### Credential Issues
-1. Double-check your API key in the file (no extra spaces)
-2. Verify key is valid at provider's website
-3. Check you added credits to your account
-4. Restart: `docker compose restart api`
+1. Go to **Settings** → **API Keys**
+2. Click **Test Connection** on the credential
+3. If it fails, verify key at provider's website
+4. Check you have credits in your account
+5. Delete and re-create the credential if needed
---
@@ -313,6 +320,18 @@ docker compose up -d
---
+## Alternative Setups
+
+Looking for different configurations? Check out our [examples/](../../examples/) folder:
+
+- **[Ollama Setup](../../examples/docker-compose-ollama.yml)** - Run local AI models (free, private)
+- **[Single Container](../../examples/docker-compose-single.yml)** - All-in-one container (deprecated, not recommended)
+- **[Development](../../examples/docker-compose-dev.yml)** - For contributors and developers
+
+Each example includes detailed comments and usage instructions.
+
+---
+
## Next Steps
1. **Add Content**: Sources, notebooks, documents
@@ -325,8 +344,8 @@ docker compose up -d
## Production Deployment
For production use, see:
-- [Security Hardening](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/security.md)
-- [Reverse Proxy](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/reverse-proxy.md)
+- [Security Hardening](../5-CONFIGURATION/security.md)
+- [Reverse Proxy](../5-CONFIGURATION/reverse-proxy.md)
---
diff --git a/docs/1-INSTALLATION/from-source.md b/docs/1-INSTALLATION/from-source.md
index ab929f6..078bdbc 100644
--- a/docs/1-INSTALLATION/from-source.md
+++ b/docs/1-INSTALLATION/from-source.md
@@ -62,11 +62,12 @@ make database
```bash
cp .env.example .env
-# Edit .env and add your API key:
-# OPENAI_API_KEY=sk-...
-# (or ANTHROPIC_API_KEY, GROQ_API_KEY, etc.)
+# Edit .env and set:
+# OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
```
+After starting the app, configure AI providers via the **Settings → API Keys** UI in the browser.
+
### 5. Start API
```bash
@@ -88,6 +89,14 @@ cd frontend && npm install && npm run dev
- **API Docs**: http://localhost:5055/docs
- **Database**: http://localhost:8000
+### 8. Configure AI Provider
+
+1. Open http://localhost:3000
+2. Go to **Settings** → **API Keys**
+3. Click **Add Credential** → Select your provider → Paste API key
+4. Click **Save**, then **Test Connection**
+5. Click **Discover Models** → **Register Models**
+
---
## Development Workflow
diff --git a/docs/1-INSTALLATION/index.md b/docs/1-INSTALLATION/index.md
index 95ad0a0..1267f21 100644
--- a/docs/1-INSTALLATION/index.md
+++ b/docs/1-INSTALLATION/index.md
@@ -144,9 +144,9 @@ Once you're up and running:
Installing for production use? See additional resources:
-- [Security Hardening](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/security.md)
-- [Reverse Proxy Setup](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/reverse-proxy.md)
-- [Performance Tuning](https://github.com/lfnovo/open-notebook/blob/main/docs/deployment/retry-configuration.md)
+- [Security Hardening](../5-CONFIGURATION/security.md)
+- [Reverse Proxy Setup](../5-CONFIGURATION/reverse-proxy.md)
+- [Performance Tuning](../5-CONFIGURATION/advanced.md)
---
diff --git a/docs/1-INSTALLATION/single-container.md b/docs/1-INSTALLATION/single-container.md
index a6911e3..0bc0db7 100644
--- a/docs/1-INSTALLATION/single-container.md
+++ b/docs/1-INSTALLATION/single-container.md
@@ -6,7 +6,7 @@ All-in-one container setup. **Simpler than Docker Compose, but less flexible.**
> **Alternative Registry:** Images available on both Docker Hub (`lfnovo/open_notebook:v1-latest-single`) and GitHub Container Registry (`ghcr.io/lfnovo/open-notebook:v1-latest-single`).
-> ⚠️ **Note**: While this is a simple way to get started, we recommend [Docker Compose](docker-compose.md) for most users. Docker Compose is more flexible and will make it easier if we add more services to the setup in the future. This single-container option is best for platforms that specifically require it (PikaPods, Railway, etc.).
+> **Note**: While this is a simple way to get started, we recommend [Docker Compose](docker-compose.md) for most users. Docker Compose is more flexible and will make it easier if we add more services to the setup in the future. This single-container option is best for platforms that specifically require it (PikaPods, Railway, etc.).
## Prerequisites
@@ -28,7 +28,7 @@ services:
- "8502:8502" # Web UI (React frontend)
- "5055:5055" # API
environment:
- - OPENAI_API_KEY=sk-...
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
- SURREAL_URL=ws://localhost:8000/rpc
- SURREAL_USER=root
- SURREAL_PASSWORD=password
@@ -46,31 +46,39 @@ docker compose up -d
Access: `http://localhost:8502`
+Then configure your AI provider:
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential** → Select your provider → Paste API key
+3. Click **Save**, then **Test Connection**
+4. Click **Discover Models** → **Register Models**
+
### For Cloud Platforms
**PikaPods:**
1. Click "New App"
2. Search "Open Notebook"
-3. Set environment variables
+3. Set environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
4. Click "Deploy"
+5. Open the app → Go to **Settings → API Keys** to configure your AI provider
**Railway:**
1. Create new project
2. Add `lfnovo/open_notebook:v1-latest-single`
-3. Set environment variables
+3. Set environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
4. Deploy
+5. Open the app → Go to **Settings → API Keys** to configure your AI provider
**Render:**
1. Create new Web Service
2. Use Docker image: `lfnovo/open_notebook:v1-latest-single`
-3. Set environment variables in dashboard
+3. Set environment variables in dashboard (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
4. Configure persistent disk for `/app/data` and `/mydata`
**DigitalOcean App Platform:**
1. Create new app from Docker Hub
2. Use image: `lfnovo/open_notebook:v1-latest-single`
3. Set port to 8502
-4. Add environment variables
+4. Add environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
5. Configure persistent storage
**Heroku:**
@@ -78,14 +86,14 @@ Access: `http://localhost:8502`
# Using heroku.yml
heroku container:push web
heroku container:release web
-heroku config:set OPENAI_API_KEY=sk-...
+heroku config:set OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
```
**Coolify:**
1. Add new service → Docker Image
2. Image: `lfnovo/open_notebook:v1-latest-single`
3. Port: 8502
-4. Add environment variables
+4. Add environment variables (at minimum: `OPEN_NOTEBOOK_ENCRYPTION_KEY`)
5. Enable persistent volumes
6. Coolify handles HTTPS automatically
@@ -95,12 +103,14 @@ heroku config:set OPENAI_API_KEY=sk-...
| Variable | Purpose | Example |
|----------|---------|---------|
-| `OPENAI_API_KEY` | API key | `sk-...` |
+| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | Encryption key for credentials (required) | `my-secret-key` |
| `SURREAL_URL` | Database | `ws://localhost:8000/rpc` |
| `SURREAL_USER` | DB user | `root` |
| `SURREAL_PASSWORD` | DB password | `password` |
| `API_URL` | External URL (for remote access) | `https://myapp.example.com` |
+AI provider API keys are configured via the **Settings → API Keys** UI after deployment.
+
---
## Limitations vs Docker Compose
@@ -119,4 +129,7 @@ heroku config:set OPENAI_API_KEY=sk-...
Same as Docker Compose setup - just access via `http://localhost:8502` (local) or your platform's URL (cloud).
+1. Go to **Settings → API Keys** to add your AI provider credential
+2. **Test Connection** and **Discover Models**
+
See [Docker Compose](docker-compose.md) for full post-install guide.
diff --git a/docs/3-USER-GUIDE/api-configuration.md b/docs/3-USER-GUIDE/api-configuration.md
new file mode 100644
index 0000000..96c3bc5
--- /dev/null
+++ b/docs/3-USER-GUIDE/api-configuration.md
@@ -0,0 +1,390 @@
+# API Configuration
+
+Configure AI provider credentials through the Settings UI. No file editing required.
+
+> **Credential System**: Open Notebook uses encrypted credentials stored in the database. Each credential connects to a provider and allows you to discover, register, and test models.
+
+---
+
+## Overview
+
+Open Notebook manages AI provider access through a **credential-based system**:
+
+1. You create a **credential** for each provider (API key + settings)
+2. Credentials are **encrypted** and stored in the database
+3. You **test connections** to verify credentials work
+4. You **discover and register models** from each credential
+5. Models are linked to credentials for direct configuration
+
+---
+
+## Encryption Setup
+
+Before storing credentials, you must configure an encryption key.
+
+### Setting the Encryption Key
+
+Add `OPEN_NOTEBOOK_ENCRYPTION_KEY` to your docker-compose.yml:
+
+```yaml
+environment:
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-passphrase
+```
+
+Any string works as a key — it will be securely derived via SHA-256 internally.
+
+> **Warning**: If you change or lose the encryption key, **all stored credentials become unreadable**. Back up your encryption key securely and separately from your database backups.
+
+### Docker Secrets Support
+
+Both password and encryption key support Docker secrets:
+
+```yaml
+# docker-compose.yml
+services:
+ open_notebook:
+ environment:
+ - OPEN_NOTEBOOK_PASSWORD_FILE=/run/secrets/app_password
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE=/run/secrets/encryption_key
+ secrets:
+ - app_password
+ - encryption_key
+
+secrets:
+ app_password:
+ file: ./secrets/password.txt
+ encryption_key:
+ file: ./secrets/encryption_key.txt
+```
+
+### Encryption Details
+
+API keys stored in the database are encrypted using Fernet (AES-128-CBC + HMAC-SHA256).
+
+| Configuration | Behavior |
+|---------------|----------|
+| Encryption key set | Keys encrypted with your key |
+| No encryption key set | Storing credentials is disabled |
+
+---
+
+## Accessing Credential Configuration
+
+1. Click **Settings** in the navigation bar
+2. Select **API Keys** tab
+3. You'll see existing credentials and an **Add Credential** button
+
+```
+Navigation: Settings → API Keys
+```
+
+---
+
+## Supported Providers
+
+### Cloud Providers
+
+| Provider | Required Fields | Optional Fields |
+|----------|-----------------|-----------------|
+| OpenAI | API Key | — |
+| Anthropic | API Key | — |
+| Google Gemini | API Key | — |
+| Groq | API Key | — |
+| Mistral | API Key | — |
+| DeepSeek | API Key | — |
+| xAI | API Key | — |
+| OpenRouter | API Key | — |
+| Voyage AI | API Key | — |
+| ElevenLabs | API Key | — |
+
+### Local/Self-Hosted
+
+| Provider | Required Fields | Notes |
+|----------|-----------------|-------|
+| Ollama | Base URL | Typically `http://localhost:11434` or `http://ollama:11434` |
+
+### Enterprise
+
+| Provider | Required Fields | Optional Fields |
+|----------|-----------------|-----------------|
+| Azure OpenAI | API Key, Endpoint, API Version | Service-specific endpoints (LLM, Embedding, STT, TTS) |
+| OpenAI-Compatible | Base URL | API Key, Service-specific configs |
+| Vertex AI | Project ID, Location, Credentials Path | — |
+
+---
+
+## Creating a Credential
+
+### Step 1: Add Credential
+
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select your provider
+4. Give it a descriptive name (e.g., "My OpenAI Key", "Work Anthropic")
+5. Fill in the required fields (API key, base URL, etc.)
+6. Click **Save**
+
+### Step 2: Test Connection
+
+1. On your new credential card, click **Test Connection**
+2. Wait for the result:
+
+| Result | Meaning |
+|--------|---------|
+| Success | Key is valid, provider accessible |
+| Invalid API key | Check key format and value |
+| Connection failed | Check URL, network, firewall |
+
+### Step 3: Discover Models
+
+1. Click **Discover Models** on the credential card
+2. The system queries the provider for available models
+3. Review the discovered models
+
+### Step 4: Register Models
+
+1. Select the models you want to use
+2. Click **Register Models**
+3. The models are now available throughout Open Notebook
+
+---
+
+## Multi-Credential Support
+
+Each provider can have **multiple credentials**. This is useful when:
+- You have different API keys for different projects
+- You want to test with different endpoints
+- Multiple team members need separate credentials
+
+### Creating Multiple Credentials
+
+1. Click **Add Credential** again
+2. Select the same provider
+3. Fill in different credentials
+4. Each credential can discover and register its own models
+
+### How Models Link to Credentials
+
+When you register models from a credential, those models are linked to that specific credential. This means:
+- Each model knows which API key to use
+- You can have models from different credentials for the same provider
+- Deleting a credential removes its linked models
+
+---
+
+## Testing Connections
+
+Click **Test Connection** to verify your credential:
+
+| Result | Meaning |
+|--------|---------|
+| Success | Key is valid, provider accessible |
+| Invalid API key | Check key format and value |
+| Connection failed | Check URL, network, firewall |
+| Model not available | Key valid but model access restricted |
+
+Test uses inexpensive models (e.g., `gpt-3.5-turbo`, `claude-3-haiku`) to minimize cost.
+
+---
+
+## Configuring Specific Providers
+
+### Simple Providers (API Key Only)
+
+For OpenAI, Anthropic, Google, Groq, Mistral, DeepSeek, xAI, OpenRouter:
+
+1. Add credential with your API key
+2. Test connection
+3. Discover and register models
+
+### Ollama (URL-Based)
+
+1. Add credential with provider **Ollama**
+2. Enter the base URL (e.g., `http://ollama:11434`)
+3. Test connection
+4. Discover and register models
+
+Ollama allows localhost and private IPs since it runs locally.
+
+### Azure OpenAI
+
+Azure requires multiple fields:
+
+| Field | Example | Required |
+|-------|---------|----------|
+| API Key | `abc123...` | Yes |
+| Endpoint | `https://myresource.openai.azure.com` | Yes |
+| API Version | `2024-02-15-preview` | Yes |
+| LLM Endpoint | `https://myresource-llm.openai.azure.com` | No |
+| Embedding Endpoint | `https://myresource-embed.openai.azure.com` | No |
+
+Service-specific endpoints override the main endpoint for that service type.
+
+### OpenAI-Compatible
+
+For custom OpenAI-compatible servers (LM Studio, vLLM, etc.):
+
+1. Add credential with provider **OpenAI-Compatible**
+2. Enter the base URL
+3. Enter API key (if required)
+4. Optionally configure per-service URLs
+
+Supports separate configurations for:
+- LLM (language models)
+- Embedding
+- STT (speech-to-text)
+- TTS (text-to-speech)
+
+### Vertex AI
+
+Google Cloud's enterprise AI platform:
+
+| Field | Example |
+|-------|---------|
+| Project ID | `my-gcp-project` |
+| Location | `us-central1` |
+| Credentials Path | `/path/to/service-account.json` |
+
+---
+
+## Migrating from Environment Variables
+
+If you have existing API keys in environment variables (from a previous version):
+
+1. Open **Settings → API Keys**
+2. A banner appears: "Environment variables detected"
+3. Click **Migrate to Database**
+4. Keys are copied to the database (encrypted)
+5. Original environment variables remain unchanged
+
+### Migration Behavior
+
+| Scenario | Action |
+|----------|--------|
+| Key in env only | Migrated to database |
+| Key in database only | No change |
+| Key in both | Database version kept (skipped) |
+
+### After Migration
+
+- Database credentials are used for all operations
+- You can remove the API key environment variables from your docker-compose.yml
+- Keep `OPEN_NOTEBOOK_ENCRYPTION_KEY` — it's still required
+
+### Migration Banner Visibility
+
+The migration banner only appears when:
+- You have environment variables configured
+- Those providers are **not** already in the database
+- If all env providers are already migrated, the banner won't show
+
+---
+
+## Migrating from ProviderConfig (v1.1 → v1.2)
+
+If you're upgrading from an older version that used the ProviderConfig system:
+
+- The migration happens automatically on first startup
+- Your existing configurations are converted to credentials
+- Check **Settings → API Keys** to verify the migration succeeded
+- If you see issues, check the API logs for migration messages
+
+---
+
+## Key Storage Security
+
+### Encryption
+
+API keys stored in the database are encrypted using Fernet (AES-128-CBC + HMAC-SHA256).
+
+| Configuration | Behavior |
+|---------------|----------|
+| Encryption key set | Keys encrypted with your key |
+| No encryption key set | Storing API keys in database is disabled |
+
+### Default Credentials
+
+| Setting | Default Value | Production Recommendation |
+|---------|---------------|---------------------------|
+| Password | `open-notebook-change-me` | Set `OPEN_NOTEBOOK_PASSWORD` |
+| Encryption Key | None (must be set) | Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` to any secret string |
+
+**For production deployments, always set custom credentials.**
+
+---
+
+## Deleting Credentials
+
+1. Click the **Delete** button on the credential card
+2. Confirm deletion
+3. Credential and all its linked models are removed from the database
+
+---
+
+## Troubleshooting
+
+### Credential Not Saving
+
+| Symptom | Cause | Solution |
+|---------|-------|----------|
+| Save button disabled | Empty or invalid input | Enter a valid key |
+| Error on save | Encryption key not set | Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in docker-compose.yml |
+| Error on save | Database connection issue | Check database status |
+
+### Test Connection Fails
+
+| Error | Cause | Solution |
+|-------|-------|----------|
+| Invalid API key | Wrong key or format | Verify key from provider dashboard |
+| Connection refused | Wrong URL | Check base URL format |
+| Timeout | Network issue | Check firewall, proxy settings |
+| 403 Forbidden | IP restriction | Whitelist your server IP |
+
+### Migration Issues
+
+| Problem | Solution |
+|---------|----------|
+| No migration banner | No env vars detected, or already migrated |
+| Partial migration | Check error list, fix and retry |
+| Keys not working after migration | Clear browser cache, restart services |
+
+### Provider Shows "Not Configured"
+
+1. Check if a credential exists for this provider (Settings → API Keys)
+2. Test the credential connection
+3. Verify key format matches provider requirements
+4. Re-discover and register models if needed
+
+---
+
+## Provider-Specific Notes
+
+### OpenAI
+- Keys start with `sk-proj-` (project keys) or `sk-` (legacy)
+- Requires billing enabled on account
+
+### Anthropic
+- Keys start with `sk-ant-`
+- Check account has API access enabled
+
+### Google Gemini
+- Keys start with `AIzaSy`
+- Free tier has rate limits
+
+### Ollama
+- No API key required
+- Default URL: `http://localhost:11434` (local) or `http://ollama:11434` (Docker)
+- Ensure Ollama server is running
+
+### Azure OpenAI
+- Endpoint format: `https://{resource-name}.openai.azure.com`
+- API version format: `YYYY-MM-DD` or `YYYY-MM-DD-preview`
+- Deployment names configured separately when registering models via the credential's Discover Models dialog
+
+---
+
+## Related
+
+- **[AI Providers](../5-CONFIGURATION/ai-providers.md)** — Provider setup instructions and recommendations
+- **[Security](../5-CONFIGURATION/security.md)** — Password and encryption configuration
+- **[Environment Reference](../5-CONFIGURATION/environment-reference.md)** — All configuration options
diff --git a/docs/3-USER-GUIDE/index.md b/docs/3-USER-GUIDE/index.md
index d5ea020..2390d2d 100644
--- a/docs/3-USER-GUIDE/index.md
+++ b/docs/3-USER-GUIDE/index.md
@@ -97,6 +97,18 @@ Verify AI claims by tracing them back to source material. Understand the citatio
---
+### 8. [API Configuration](api-configuration.md)
+Configure AI provider API keys directly through the Settings UI.
+
+**Quick links:**
+- Add API keys without editing files
+- Test provider connections
+- Migrate from environment variables
+- Manage Azure and OpenAI-compatible providers
+- Understand key storage and encryption
+
+---
+
## Which Feature for Which Task?
```
@@ -120,6 +132,9 @@ Task: "I want to find that quote I remember"
Task: "I'm exploring a concept without knowing exact words"
→ Use: Search / Vector Search (semantic similarity)
+
+Task: "I need to add or change my AI provider API keys"
+→ Use: Settings / API Keys (configure providers without editing files)
```
---
diff --git a/docs/4-AI-PROVIDERS/index.md b/docs/4-AI-PROVIDERS/index.md
index c291832..be478da 100644
--- a/docs/4-AI-PROVIDERS/index.md
+++ b/docs/4-AI-PROVIDERS/index.md
@@ -142,8 +142,8 @@ Now that you've chosen a provider, follow the detailed setup instructions:
→ **[AI Providers Configuration Guide](../5-CONFIGURATION/ai-providers.md)**
This guide includes:
-- Step-by-step setup instructions for each provider
-- Environment variable configuration
+- Step-by-step setup instructions for each provider via the Settings UI
+- How to add credentials, test connections, and discover models
- Model selection and recommendations
- Provider-specific troubleshooting
- Hardware requirements (for local providers)
@@ -184,11 +184,11 @@ Any use: Free (electricity only)
## Next Steps
-1. ✅ **You've chosen a provider** (from this comparison guide)
-2. 📖 **Follow the setup guide**: [AI Providers Configuration](../5-CONFIGURATION/ai-providers.md)
-3. ⚙️ **Configure your environment** (detailed in the setup guide)
-4. 🧪 **Test your setup** in Settings → Models
-5. 🚀 **Start using Open Notebook!**
+1. **You've chosen a provider** (from this comparison guide)
+2. **Follow the setup guide**: [AI Providers Configuration](../5-CONFIGURATION/ai-providers.md)
+3. **Add your credential** in Settings → API Keys
+4. **Test your connection** and discover models
+5. **Start using Open Notebook!**
---
diff --git a/docs/5-CONFIGURATION/advanced.md b/docs/5-CONFIGURATION/advanced.md
index cc98fb9..7340b90 100644
--- a/docs/5-CONFIGURATION/advanced.md
+++ b/docs/5-CONFIGURATION/advanced.md
@@ -197,28 +197,21 @@ ESPERANTO_SSL_VERIFY=false
### Use Different Providers for Different Tasks
-```env
-# Language model (main)
-OPENAI_API_KEY=sk-proj-...
+Configure multiple AI providers via **Settings → API Keys**. Each provider gets its own credential:
-# Embeddings (alternative)
-# (Future: Configure different embedding provider)
+1. Add a credential for your main language model provider (e.g., OpenAI, Anthropic)
+2. Add a credential for embeddings (e.g., Voyage AI, or use the same provider)
+3. Add a credential for TTS (e.g., ElevenLabs, or OpenAI-Compatible for local Speaches)
+4. Each credential's models are registered and available independently
-# TTS (different provider)
-ELEVENLABS_API_KEY=...
-```
+### Multiple Endpoints for OpenAI-Compatible
-### OpenAI-Compatible with Fallback
+When using OpenAI-Compatible providers, you can configure per-service URLs in a single credential:
-```env
-# Primary
-OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
-OPENAI_COMPATIBLE_API_KEY=key1
-
-# Can also set specific modality endpoints
-OPENAI_COMPATIBLE_BASE_URL_LLM=http://localhost:1234/v1
-OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:8001/v1
-```
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential** → Select **OpenAI-Compatible**
+3. Configure separate URLs for LLM, Embedding, TTS, and STT
+4. Click **Save**, then **Test Connection**
---
@@ -283,25 +276,12 @@ Get key from: https://jina.ai/
## Environment Variable Groups
-### API Keys (Choose at least one)
+### Credential Storage (Required)
```env
-OPENAI_API_KEY
-ANTHROPIC_API_KEY
-GOOGLE_API_KEY
-GROQ_API_KEY
-MISTRAL_API_KEY
-DEEPSEEK_API_KEY
-OPENROUTER_API_KEY
-XAI_API_KEY
+OPEN_NOTEBOOK_ENCRYPTION_KEY # Required for storing credentials
```
-### AI Provider Endpoints
-```env
-OLLAMA_API_BASE
-OPENAI_COMPATIBLE_BASE_URL
-AZURE_OPENAI_ENDPOINT
-GEMINI_API_BASE_URL
-```
+AI provider API keys are configured via **Settings → API Keys** (not environment variables).
### Database
```env
@@ -332,10 +312,11 @@ ESPERANTO_LLM_TIMEOUT
### Audio/TTS
```env
-ELEVENLABS_API_KEY
TTS_BATCH_SIZE
```
+> **Note:** `ELEVENLABS_API_KEY` is deprecated. Configure ElevenLabs via **Settings → API Keys**.
+
### Debugging
```env
LANGCHAIN_TRACING_V2
@@ -351,14 +332,10 @@ LANGCHAIN_PROJECT
### Quick Test
```bash
-# Add test config
-export OPENAI_API_KEY=sk-test-key
-export API_URL=http://localhost:5055
-
-# Test connection
+# Test API health
curl http://localhost:5055/health
-# Test with sample
+# Test with sample (requires configured credential and registered models)
curl -X POST http://localhost:5055/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"Hello"}'
@@ -368,7 +345,7 @@ curl -X POST http://localhost:5055/api/chat \
```bash
# Check environment variables are set
-env | grep OPENAI_API_KEY
+env | grep OPEN_NOTEBOOK_ENCRYPTION_KEY
# Verify database connection
python -c "import os; print(os.getenv('SURREAL_URL'))"
diff --git a/docs/5-CONFIGURATION/ai-providers.md b/docs/5-CONFIGURATION/ai-providers.md
index e94443b..b6a4134 100644
--- a/docs/5-CONFIGURATION/ai-providers.md
+++ b/docs/5-CONFIGURATION/ai-providers.md
@@ -1,6 +1,22 @@
-# AI Providers - Configuration Reference
+# AI Providers - Configuration Guide
-Complete setup instructions for each AI provider. Pick the one you're using.
+Complete setup instructions for each AI provider via the **Settings UI**.
+
+> **New in v1.2**: All AI provider credentials are now managed through the Settings UI. Environment variables for API keys are deprecated.
+
+---
+
+## How Provider Setup Works
+
+Open Notebook uses a **credential-based system** for managing AI providers:
+
+1. **Get your API key** from the provider's website
+2. **Open Settings** → **API Keys** → **Add Credential**
+3. **Test the connection** to verify it works
+4. **Discover & Register Models** to make them available
+5. **Start using** the provider in your notebooks
+
+> **Prerequisite**: You must set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in your docker-compose.yml before storing credentials. See [API Configuration](../3-USER-GUIDE/api-configuration.md#encryption-setup) for details.
---
@@ -10,21 +26,21 @@ Complete setup instructions for each AI provider. Pick the one you're using.
**Cost:** ~$0.03-0.15 per 1K tokens (varies by model)
-**Setup:**
-```bash
+**Get Your API Key:**
1. Go to https://platform.openai.com/api-keys
2. Create account (if needed)
3. Create new API key (starts with "sk-proj-")
4. Add $5+ credits to account
-5. Add to .env:
- OPENAI_API_KEY=sk-proj-...
-6. Restart services
-```
-**Environment Variable:**
-```
-OPENAI_API_KEY=sk-proj-xxxxx
-```
+**Configure in Open Notebook:**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **OpenAI**
+4. Give it a name (e.g., "My OpenAI Key")
+5. Paste your API key
+6. Click **Save**, then **Test Connection**
+7. Click **Discover Models** to find available models
+8. Click **Register Models** to make them available
**Available Models (in Open Notebook):**
- `gpt-4o` — Best quality, fast (latest version)
@@ -45,9 +61,9 @@ Heavy use: $50-100+/month
```
**Troubleshooting:**
-- "Invalid API key" → Check key starts with "sk-proj-"
+- "Invalid API key" → Check key starts with "sk-proj-" and test the connection in Settings
- "Rate limit exceeded" → Wait or upgrade account
-- "Model not available" → Try gpt-4o-mini instead
+- "Model not available" → Try gpt-4o-mini instead, or re-discover models
---
@@ -55,21 +71,19 @@ Heavy use: $50-100+/month
**Cost:** ~$0.80-3.00 per 1M tokens (cheaper than OpenAI for long context)
-**Setup:**
-```bash
+**Get Your API Key:**
1. Go to https://console.anthropic.com/
2. Create account or login
3. Go to API keys section
4. Create new API key (starts with "sk-ant-")
-5. Add to .env:
- ANTHROPIC_API_KEY=sk-ant-...
-6. Restart services
-```
-**Environment Variable:**
-```
-ANTHROPIC_API_KEY=sk-ant-xxxxx
-```
+**Configure in Open Notebook:**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **Anthropic**
+4. Give it a name, paste your API key
+5. Click **Save**, then **Test Connection**
+6. Click **Discover Models** → **Register Models**
**Available Models:**
- `claude-sonnet-4-5-20250929` — Latest, best quality (recommended)
@@ -95,9 +109,9 @@ Opus: $10-50+/month
- Fast processing
**Troubleshooting:**
-- "Invalid API key" → Check it starts with "sk-ant-"
+- "Invalid API key" → Check it starts with "sk-ant-" and test in Settings
- "Overloaded" → Anthropic is busy, retry later
-- "Model unavailable" → Check model name is correct
+- "Model unavailable" → Re-discover models from the credential
---
@@ -105,28 +119,22 @@ Opus: $10-50+/month
**Cost:** ~$0.075-0.30 per 1K tokens (competitive with OpenAI)
-**Setup:**
-```bash
+**Get Your API Key:**
1. Go to https://aistudio.google.com/app/apikey
2. Create account or login
3. Create new API key
-4. Add to .env:
- GOOGLE_API_KEY=AIzaSy...
-5. Restart services
-```
-**Environment Variable:**
-```
-GOOGLE_API_KEY=AIzaSy...
-# Optional: override default endpoint
-GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models
-```
+**Configure in Open Notebook:**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **Google Gemini**
+4. Give it a name, paste your API key
+5. Click **Save**, then **Test Connection**
+6. Click **Discover Models** → **Register Models**
**Available Models:**
- `gemini-2.0-flash-exp` — Latest experimental, fastest (recommended)
- `gemini-2.0-flash` — Stable version, fast, cheap
-- `gemini-1.5-pro-latest` — More capable, longer context
-- `gemini-1.5-flash` — Previous generation, very cheap
**Recommended:**
- For general use: `gemini-2.0-flash-exp` (best value, latest)
@@ -141,7 +149,7 @@ GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models
**Troubleshooting:**
- "API key invalid" → Get fresh key from aistudio.google.com
- "Quota exceeded" → Free tier limited, upgrade account
-- "Model not found" → Check model name spelling
+- "Model not found" → Re-discover models from the credential
---
@@ -149,20 +157,18 @@ GEMINI_API_BASE_URL=https://generativelanguage.googleapis.com/v1beta/models
**Cost:** ~$0.05 per 1M tokens (cheapest, but limited models)
-**Setup:**
-```bash
+**Get Your API Key:**
1. Go to https://console.groq.com/keys
2. Create account or login
3. Create new API key
-4. Add to .env:
- GROQ_API_KEY=gsk_...
-5. Restart services
-```
-**Environment Variable:**
-```
-GROQ_API_KEY=gsk_xxxxx
-```
+**Configure in Open Notebook:**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **Groq**
+4. Give it a name, paste your API key
+5. Click **Save**, then **Test Connection**
+6. Click **Discover Models** → **Register Models**
**Available Models:**
- `llama-3.3-70b-versatile` — Best on Groq (recommended)
@@ -186,7 +192,7 @@ GROQ_API_KEY=gsk_xxxxx
**Troubleshooting:**
- "Rate limited" → Free tier has limits, upgrade
-- "Model not available" → Check supported models list
+- "Model not available" → Re-discover models from the credential
---
@@ -194,21 +200,19 @@ GROQ_API_KEY=gsk_xxxxx
**Cost:** Varies by model ($0.05-15 per 1M tokens)
-**Setup:**
-```bash
+**Get Your API Key:**
1. Go to https://openrouter.ai/keys
2. Create account or login
3. Add credits to your account
4. Create new API key
-5. Add to .env:
- OPENROUTER_API_KEY=sk-or-...
-6. Restart services
-```
-**Environment Variable:**
-```
-OPENROUTER_API_KEY=sk-or-xxxxx
-```
+**Configure in Open Notebook:**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **OpenRouter**
+4. Give it a name, paste your API key
+5. Click **Save**, then **Test Connection**
+6. Click **Discover Models** → **Register Models**
**Available Models (100+ options):**
- OpenAI: `openai/gpt-4o`, `openai/o1`
@@ -251,28 +255,24 @@ Heavy use: Depends on models chosen
**Cost:** Free (electricity only)
-**Setup:**
-```bash
+**Setup Ollama:**
1. Install Ollama: https://ollama.ai
-2. Run Ollama in background:
- ollama serve
+2. Run Ollama in background: `ollama serve`
+3. Download a model: `ollama pull mistral`
-3. Download a model:
- ollama pull mistral
- # or llama2, neural-chat, phi, etc.
+**Configure in Open Notebook:**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **Ollama**
+4. Give it a name (e.g., "Local Ollama")
+5. Enter the base URL:
+ - Same machine (non-Docker): `http://localhost:11434`
+ - Docker with Ollama on host: `http://host.docker.internal:11434`
+ - Docker with Ollama container: `http://ollama:11434`
+6. Click **Save**, then **Test Connection**
+7. Click **Discover Models** → **Register Models**
-4. Add to .env:
- OLLAMA_API_BASE=http://localhost:11434
- # If on different machine:
- # OLLAMA_API_BASE=http://10.0.0.5:11434
-
-5. Restart services
-```
-
-**Environment Variable:**
-```
-OLLAMA_API_BASE=http://localhost:11434
-```
+See [Ollama Setup Guide](ollama.md) for detailed network configuration.
**Available Models:**
- `llama3.3:70b` — Best quality (requires 40GB+ RAM)
@@ -314,7 +314,7 @@ CPU-only:
- Requires local hardware
**Troubleshooting:**
-- "Connection refused" → Ollama not running or wrong port
+- "Connection refused" → Ollama not running or wrong URL in credential
- "Model not found" → Download it: `ollama pull modelname`
- "Out of memory" → Use smaller model or add more RAM
@@ -324,24 +324,21 @@ CPU-only:
**Cost:** Free
-**Setup:**
-```bash
+**Setup LM Studio:**
1. Download LM Studio: https://lmstudio.ai
2. Open app
3. Download a model from library
4. Go to "Local Server" tab
5. Start server (default port: 1234)
-6. Add to .env:
- OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
- OPENAI_COMPATIBLE_API_KEY=not-needed
-7. Restart services
-```
-**Environment Variables:**
-```
-OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
-OPENAI_COMPATIBLE_API_KEY=lm-studio # Just a placeholder
-```
+**Configure in Open Notebook:**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **OpenAI-Compatible**
+4. Give it a name (e.g., "LM Studio")
+5. Enter the base URL: `http://host.docker.internal:1234/v1` (Docker) or `http://localhost:1234/v1` (local)
+6. API key: `lm-studio` (placeholder, LM Studio doesn't require one)
+7. Click **Save**, then **Test Connection**
**Advantages:**
- GUI interface (easier than Ollama CLI)
@@ -360,27 +357,15 @@ OPENAI_COMPATIBLE_API_KEY=lm-studio # Just a placeholder
For Text Generation UI, vLLM, or other OpenAI-compatible endpoints:
-```bash
-Add to .env:
-OPENAI_COMPATIBLE_BASE_URL=http://your-endpoint/v1
-OPENAI_COMPATIBLE_API_KEY=your-api-key
-```
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential**
+3. Select provider: **OpenAI-Compatible**
+4. Enter the base URL for your endpoint (e.g., `http://localhost:8000/v1`)
+5. Enter API key if required
+6. Optionally configure per-service URLs (LLM, Embedding, TTS, STT)
+7. Click **Save**, then **Test Connection**
-If you need different endpoints for different modalities:
-
-```bash
-# Language model
-OPENAI_COMPATIBLE_BASE_URL_LLM=http://localhost:8000/v1
-OPENAI_COMPATIBLE_API_KEY_LLM=sk-...
-
-# Embeddings
-OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:8001/v1
-OPENAI_COMPATIBLE_API_KEY_EMBEDDING=sk-...
-
-# TTS (text-to-speech)
-OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8002/v1
-OPENAI_COMPATIBLE_API_KEY_TTS=sk-...
-```
+See [OpenAI-Compatible Setup](openai-compatible.md) for detailed instructions.
---
@@ -390,29 +375,16 @@ OPENAI_COMPATIBLE_API_KEY_TTS=sk-...
**Cost:** Same as OpenAI (usage-based)
-**Setup:**
-```bash
+**Configure in Open Notebook:**
1. Create Azure OpenAI service in Azure portal
2. Deploy GPT-4/3.5-turbo model
3. Get your endpoint and key
-4. Add to .env:
- AZURE_OPENAI_API_KEY=your-key
- AZURE_OPENAI_ENDPOINT=https://your-name.openai.azure.com/
- AZURE_OPENAI_API_VERSION=2024-12-01-preview
-5. Restart services
-```
-
-**Environment Variables:**
-```
-AZURE_OPENAI_API_KEY=xxxxx
-AZURE_OPENAI_ENDPOINT=https://your-instance.openai.azure.com/
-AZURE_OPENAI_API_VERSION=2024-12-01-preview
-
-# Optional: Different deployments for different modalities
-AZURE_OPENAI_API_KEY_LLM=xxxxx
-AZURE_OPENAI_ENDPOINT_LLM=https://your-instance.openai.azure.com/
-AZURE_OPENAI_API_VERSION_LLM=2024-12-01-preview
-```
+4. Go to **Settings** → **API Keys**
+5. Click **Add Credential**
+6. Select provider: **Azure OpenAI**
+7. Fill in: API Key, Endpoint, API Version
+8. Optionally configure service-specific endpoints (LLM, Embedding)
+9. Click **Save**, then **Test Connection**
**Advantages:**
- Enterprise support
@@ -428,26 +400,13 @@ AZURE_OPENAI_API_VERSION_LLM=2024-12-01-preview
## Embeddings (For Search/Semantic Features)
-By default, Open Notebook uses the LLM provider's embeddings. To use a different provider:
-
-### OpenAI Embeddings (Default)
-```
-# Uses OpenAI's embedding model automatically
-# Requires OPENAI_API_KEY
-# No separate configuration needed
-```
-
-### Custom Embeddings
-```
-# For other embedding providers (future feature)
-EMBEDDING_PROVIDER=openai # or custom
-```
+By default, Open Notebook uses the LLM provider's embeddings. Embedding models are discovered and registered through the same credential system — when you discover models from a credential, embedding models are included alongside language models.
---
## Choosing Your Provider
-**1. Don't want to run locally and don't want to mess around with different providers:**
+**1. Don't want to run locally and don't want to mess around with different providers:**
Use OpenAI
- Cloud-based
@@ -476,17 +435,29 @@ Use OpenAI
1. **Choose your provider** from above
2. **Get API key** (if cloud) or install locally (if Ollama)
-3. **Add to .env**
-4. **Restart services**
-5. **Go to Settings → Models** in Open Notebook
-6. **Verify it works** with a test chat
+3. **Set `OPEN_NOTEBOOK_ENCRYPTION_KEY`** in your docker-compose.yml (required for storing credentials)
+4. **Open Settings** → **API Keys** → **Add Credential**
+5. **Test Connection** to verify it works
+6. **Discover & Register Models** to make them available
+7. **Verify it works** with a test chat
+
+> **Multiple providers**: You can add credentials for as many providers as you want. Create separate credentials for different projects or team members.
Done!
---
+## Legacy: Environment Variables (Deprecated)
+
+> **Deprecated**: Configuring AI provider API keys via environment variables is deprecated. Use the Settings UI instead. Environment variables may still work as a fallback but are no longer the recommended approach.
+
+If you are migrating from an older version that used environment variables, go to **Settings** → **API Keys** and click the **Migrate to Database** button to import your existing keys into the credential system.
+
+---
+
## Related
+- **[API Configuration](../3-USER-GUIDE/api-configuration.md)** — Detailed credential management guide
- **[Environment Reference](environment-reference.md)** - Complete list of all environment variables
- **[Advanced Configuration](advanced.md)** - Timeouts, SSL, performance tuning
- **[Ollama Setup](ollama.md)** - Detailed Ollama configuration guide
diff --git a/docs/5-CONFIGURATION/environment-reference.md b/docs/5-CONFIGURATION/environment-reference.md
index b7e8a26..decb10d 100644
--- a/docs/5-CONFIGURATION/environment-reference.md
+++ b/docs/5-CONFIGURATION/environment-reference.md
@@ -12,160 +12,10 @@ Comprehensive list of all environment variables available in Open Notebook.
| `INTERNAL_API_URL` | No | http://localhost:5055 | Internal API URL for Next.js server-side proxying |
| `API_CLIENT_TIMEOUT` | No | 300 | Client timeout in seconds (how long to wait for API response) |
| `OPEN_NOTEBOOK_PASSWORD` | No | None | Password to protect Open Notebook instance |
+| `OPEN_NOTEBOOK_ENCRYPTION_KEY` | **Yes** | None | Secret string to encrypt credentials stored in database (any string works). **Required** for the credential system. Supports Docker secrets via `_FILE` suffix. |
| `HOSTNAME` | No | `0.0.0.0` (in Docker) | Network interface for Next.js to bind to. Default `0.0.0.0` ensures accessibility from reverse proxies |
----
-
-## AI Provider: OpenAI
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `OPENAI_API_KEY` | If using OpenAI | None | OpenAI API key (starts with `sk-`) |
-
-**Setup:** Get from https://platform.openai.com/api-keys
-
----
-
-## AI Provider: Anthropic (Claude)
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `ANTHROPIC_API_KEY` | If using Anthropic | None | Claude API key (starts with `sk-ant-`) |
-
-**Setup:** Get from https://console.anthropic.com/
-
----
-
-## AI Provider: Google Gemini
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `GOOGLE_API_KEY` | If using Google | None | Google API key for Gemini |
-| `GEMINI_API_BASE_URL` | No | Default endpoint | Override Gemini API endpoint |
-| `VERTEX_PROJECT` | If using Vertex AI | None | Google Cloud project ID |
-| `VERTEX_LOCATION` | If using Vertex AI | us-east5 | Vertex AI location |
-| `GOOGLE_APPLICATION_CREDENTIALS` | If using Vertex AI | None | Path to service account JSON |
-
-**Setup:** Get from https://aistudio.google.com/app/apikey
-
----
-
-## AI Provider: Groq
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `GROQ_API_KEY` | If using Groq | None | Groq API key (starts with `gsk_`) |
-
-**Setup:** Get from https://console.groq.com/keys
-
----
-
-## AI Provider: Mistral
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `MISTRAL_API_KEY` | If using Mistral | None | Mistral API key |
-
-**Setup:** Get from https://console.mistral.ai/
-
----
-
-## AI Provider: DeepSeek
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `DEEPSEEK_API_KEY` | If using DeepSeek | None | DeepSeek API key |
-
-**Setup:** Get from https://platform.deepseek.com/
-
----
-
-## AI Provider: xAI
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `XAI_API_KEY` | If using xAI | None | xAI API key |
-
-**Setup:** Get from https://console.x.ai/
-
----
-
-## AI Provider: Ollama (Local)
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `OLLAMA_API_BASE` | If using Ollama | None | Ollama endpoint (e.g., http://localhost:11434) |
-
-**Setup:** Install from https://ollama.ai
-
----
-
-## AI Provider: OpenRouter
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `OPENROUTER_API_KEY` | If using OpenRouter | None | OpenRouter API key |
-| `OPENROUTER_BASE_URL` | No | https://openrouter.ai/api/v1 | OpenRouter endpoint |
-
-**Setup:** Get from https://openrouter.ai/
-
----
-
-## AI Provider: OpenAI-Compatible
-
-For self-hosted LLMs, LM Studio, or OpenAI-compatible endpoints:
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `OPENAI_COMPATIBLE_BASE_URL` | If using compatible | None | Base URL for OpenAI-compatible endpoint |
-| `OPENAI_COMPATIBLE_API_KEY` | If using compatible | None | API key for endpoint |
-| `OPENAI_COMPATIBLE_BASE_URL_LLM` | No | Uses generic | Language model endpoint (overrides generic) |
-| `OPENAI_COMPATIBLE_API_KEY_LLM` | No | Uses generic | Language model API key (overrides generic) |
-| `OPENAI_COMPATIBLE_BASE_URL_EMBEDDING` | No | Uses generic | Embedding endpoint (overrides generic) |
-| `OPENAI_COMPATIBLE_API_KEY_EMBEDDING` | No | Uses generic | Embedding API key (overrides generic) |
-| `OPENAI_COMPATIBLE_BASE_URL_STT` | No | Uses generic | Speech-to-text endpoint (overrides generic) |
-| `OPENAI_COMPATIBLE_API_KEY_STT` | No | Uses generic | STT API key (overrides generic) |
-| `OPENAI_COMPATIBLE_BASE_URL_TTS` | No | Uses generic | Text-to-speech endpoint (overrides generic) |
-| `OPENAI_COMPATIBLE_API_KEY_TTS` | No | Uses generic | TTS API key (overrides generic) |
-
-**Setup:** For LM Studio, typically: `OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1`
-
----
-
-## AI Provider: Azure OpenAI
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `AZURE_OPENAI_API_KEY` | If using Azure | None | Azure OpenAI API key |
-| `AZURE_OPENAI_ENDPOINT` | If using Azure | None | Azure OpenAI endpoint URL |
-| `AZURE_OPENAI_API_VERSION` | No | 2024-12-01-preview | Azure OpenAI API version |
-| `AZURE_OPENAI_API_KEY_LLM` | No | Uses generic | LLM-specific API key |
-| `AZURE_OPENAI_ENDPOINT_LLM` | No | Uses generic | LLM-specific endpoint |
-| `AZURE_OPENAI_API_VERSION_LLM` | No | Uses generic | LLM-specific API version |
-| `AZURE_OPENAI_API_KEY_EMBEDDING` | No | Uses generic | Embedding-specific API key |
-| `AZURE_OPENAI_ENDPOINT_EMBEDDING` | No | Uses generic | Embedding-specific endpoint |
-| `AZURE_OPENAI_API_VERSION_EMBEDDING` | No | Uses generic | Embedding-specific API version |
-
----
-
-## AI Provider: VoyageAI (Embeddings)
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `VOYAGE_API_KEY` | If using Voyage | None | Voyage AI API key (for embeddings) |
-
-**Setup:** Get from https://www.voyageai.com/
-
----
-
-## Text-to-Speech (TTS)
-
-| Variable | Required? | Default | Description |
-|----------|-----------|---------|-------------|
-| `ELEVENLABS_API_KEY` | If using ElevenLabs TTS | None | ElevenLabs API key for voice generation |
-| `TTS_BATCH_SIZE` | No | 5 | Concurrent TTS requests (1-5, depends on provider) |
-
-**Setup:** Get from https://elevenlabs.io/
+> **Important**: `OPEN_NOTEBOOK_ENCRYPTION_KEY` is required for storing AI provider credentials via the Settings UI. Without it, you cannot save credentials. If you change or lose this key, all stored credentials become unreadable.
---
@@ -211,6 +61,14 @@ For self-hosted LLMs, LM Studio, or OpenAI-compatible endpoints:
---
+## Text-to-Speech (TTS)
+
+| Variable | Required? | Default | Description |
+|----------|-----------|---------|-------------|
+| `TTS_BATCH_SIZE` | No | 5 | Concurrent TTS requests (1-5, depends on provider) |
+
+---
+
## Content Extraction
| Variable | Required? | Default | Description |
@@ -274,19 +132,21 @@ NO_PROXY=localhost,127.0.0.1,.local
## Environment Variables by Use Case
-### Minimal Setup (Cloud Provider)
+### Minimal Setup (New Installation)
```
-OPENAI_API_KEY (or ANTHROPIC_API_KEY, etc.)
+OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
+SURREAL_URL=ws://surrealdb:8000/rpc
+SURREAL_USER=root
+SURREAL_PASSWORD=password
+SURREAL_NAMESPACE=open_notebook
+SURREAL_DATABASE=open_notebook
```
+Then configure AI providers via **Settings → API Keys** in the browser.
-### Local Development (Ollama)
+### Production Deployment
```
-OLLAMA_API_BASE=http://localhost:11434
-```
-
-### Production (OpenAI + Custom Domain)
-```
-OPENAI_API_KEY=sk-proj-...
+OPEN_NOTEBOOK_ENCRYPTION_KEY=your-strong-secret-key
+OPEN_NOTEBOOK_PASSWORD=your-secure-password
API_URL=https://mynotebook.example.com
SURREAL_USER=production_user
SURREAL_PASSWORD=secure_password
@@ -294,13 +154,13 @@ SURREAL_PASSWORD=secure_password
### Self-Hosted Behind Reverse Proxy
```
-OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
+OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
API_URL=https://mynotebook.example.com
```
### Corporate Environment (Behind Proxy)
```
-OPENAI_API_KEY=sk-proj-...
+OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
HTTP_PROXY=http://proxy.corp.com:8080
HTTPS_PROXY=http://proxy.corp.com:8080
NO_PROXY=localhost,127.0.0.1
@@ -308,7 +168,7 @@ NO_PROXY=localhost,127.0.0.1
### High-Performance Deployment
```
-OPENAI_API_KEY=sk-proj-...
+OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
SURREAL_COMMANDS_MAX_TASKS=10
TTS_BATCH_SIZE=5
API_CLIENT_TIMEOUT=600
@@ -316,7 +176,7 @@ API_CLIENT_TIMEOUT=600
### Debugging
```
-OPENAI_API_KEY=sk-proj-...
+OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-key
LANGCHAIN_TRACING_V2=true
LANGCHAIN_API_KEY=your-key
```
@@ -329,10 +189,10 @@ Check if a variable is set:
```bash
# Check single variable
-echo $OPENAI_API_KEY
+echo $OPEN_NOTEBOOK_ENCRYPTION_KEY
# Check multiple
-env | grep -E "OPENAI|API_URL"
+env | grep -E "OPEN_NOTEBOOK|API_URL"
# Print all config
env | grep -E "^[A-Z_]+=" | sort
@@ -342,23 +202,73 @@ env | grep -E "^[A-Z_]+=" | sort
## Notes
-- **Case-sensitive:** `OPENAI_API_KEY` ≠ `openai_api_key`
-- **No spaces:** `OPENAI_API_KEY=sk-proj-...` not `OPENAI_API_KEY = sk-proj-...`
+- **Case-sensitive:** `OPEN_NOTEBOOK_ENCRYPTION_KEY` ≠ `open_notebook_encryption_key`
+- **No spaces:** `OPEN_NOTEBOOK_ENCRYPTION_KEY=my-key` not `OPEN_NOTEBOOK_ENCRYPTION_KEY = my-key`
- **Quote values:** Use quotes for values with spaces: `API_URL="http://my server:5055"`
- **Restart required:** Changes take effect after restarting services
-- **Secrets:** Don't commit API keys to git
+- **Secrets:** Don't commit encryption keys or passwords to git
+- **AI Providers:** Configure via **Settings → API Keys** in the browser (not via env vars)
+- **Migration:** Use Settings UI to migrate existing env vars to the credential system. See [API Configuration](../3-USER-GUIDE/api-configuration.md#migrating-from-environment-variables)
---
## Quick Setup Checklist
-- [ ] Choose AI provider (OpenAI, Anthropic, Ollama, etc.)
-- [ ] Get API key if cloud provider
-- [ ] Add to .env or docker.env
+- [ ] Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in docker-compose.yml
+- [ ] Set database credentials (`SURREAL_*`)
+- [ ] Start services
+- [ ] Open browser → Go to **Settings → API Keys**
+- [ ] **Add Credential** for your AI provider
+- [ ] **Test Connection** to verify
+- [ ] **Discover & Register Models**
- [ ] Set `API_URL` if behind reverse proxy
- [ ] Change `SURREAL_PASSWORD` in production
-- [ ] Verify with: `docker compose logs api | grep -i "error"`
-- [ ] Test in browser: Go to Settings → Models
- [ ] Try a test chat
Done!
+
+---
+
+## Legacy: AI Provider Environment Variables (Deprecated)
+
+> **Deprecated**: The following AI provider API key environment variables are deprecated. Configure providers via the Settings UI instead. These variables may still work as a fallback but are no longer recommended.
+
+If you have these variables configured from a previous installation, click the **Migrate to Database** button in **Settings → API Keys** to import them into the credential system, then remove them from your configuration.
+
+| Variable | Provider | Replacement |
+|----------|----------|-------------|
+| `OPENAI_API_KEY` | OpenAI | Settings → API Keys → Add OpenAI Credential |
+| `ANTHROPIC_API_KEY` | Anthropic | Settings → API Keys → Add Anthropic Credential |
+| `GOOGLE_API_KEY` | Google Gemini | Settings → API Keys → Add Google Credential |
+| `GEMINI_API_BASE_URL` | Google Gemini | Configure in Google Gemini credential |
+| `VERTEX_PROJECT` | Vertex AI | Settings → API Keys → Add Vertex AI Credential |
+| `VERTEX_LOCATION` | Vertex AI | Configure in Vertex AI credential |
+| `GOOGLE_APPLICATION_CREDENTIALS` | Vertex AI | Configure in Vertex AI credential |
+| `GROQ_API_KEY` | Groq | Settings → API Keys → Add Groq Credential |
+| `MISTRAL_API_KEY` | Mistral | Settings → API Keys → Add Mistral Credential |
+| `DEEPSEEK_API_KEY` | DeepSeek | Settings → API Keys → Add DeepSeek Credential |
+| `XAI_API_KEY` | xAI | Settings → API Keys → Add xAI Credential |
+| `OLLAMA_API_BASE` | Ollama | Settings → API Keys → Add Ollama Credential |
+| `OPENROUTER_API_KEY` | OpenRouter | Settings → API Keys → Add OpenRouter Credential |
+| `OPENROUTER_BASE_URL` | OpenRouter | Configure in OpenRouter credential |
+| `VOYAGE_API_KEY` | Voyage AI | Settings → API Keys → Add Voyage AI Credential |
+| `ELEVENLABS_API_KEY` | ElevenLabs | Settings → API Keys → Add ElevenLabs Credential |
+| `OPENAI_COMPATIBLE_BASE_URL` | OpenAI-Compatible | Settings → API Keys → Add OpenAI-Compatible Credential |
+| `OPENAI_COMPATIBLE_API_KEY` | OpenAI-Compatible | Configure in OpenAI-Compatible credential |
+| `OPENAI_COMPATIBLE_BASE_URL_LLM` | OpenAI-Compatible | Configure per-service URL in credential |
+| `OPENAI_COMPATIBLE_API_KEY_LLM` | OpenAI-Compatible | Configure per-service key in credential |
+| `OPENAI_COMPATIBLE_BASE_URL_EMBEDDING` | OpenAI-Compatible | Configure per-service URL in credential |
+| `OPENAI_COMPATIBLE_API_KEY_EMBEDDING` | OpenAI-Compatible | Configure per-service key in credential |
+| `OPENAI_COMPATIBLE_BASE_URL_STT` | OpenAI-Compatible | Configure per-service URL in credential |
+| `OPENAI_COMPATIBLE_API_KEY_STT` | OpenAI-Compatible | Configure per-service key in credential |
+| `OPENAI_COMPATIBLE_BASE_URL_TTS` | OpenAI-Compatible | Configure per-service URL in credential |
+| `OPENAI_COMPATIBLE_API_KEY_TTS` | OpenAI-Compatible | Configure per-service key in credential |
+| `AZURE_OPENAI_API_KEY` | Azure OpenAI | Settings → API Keys → Add Azure OpenAI Credential |
+| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI | Configure in Azure OpenAI credential |
+| `AZURE_OPENAI_API_VERSION` | Azure OpenAI | Configure in Azure OpenAI credential |
+| `AZURE_OPENAI_API_KEY_LLM` | Azure OpenAI | Configure per-service in credential |
+| `AZURE_OPENAI_ENDPOINT_LLM` | Azure OpenAI | Configure per-service in credential |
+| `AZURE_OPENAI_API_VERSION_LLM` | Azure OpenAI | Configure per-service in credential |
+| `AZURE_OPENAI_API_KEY_EMBEDDING` | Azure OpenAI | Configure per-service in credential |
+| `AZURE_OPENAI_ENDPOINT_EMBEDDING` | Azure OpenAI | Configure per-service in credential |
+| `AZURE_OPENAI_API_VERSION_EMBEDDING` | Azure OpenAI | Configure per-service in credential |
diff --git a/docs/5-CONFIGURATION/index.md b/docs/5-CONFIGURATION/index.md
index 561e133..f119836 100644
--- a/docs/5-CONFIGURATION/index.md
+++ b/docs/5-CONFIGURATION/index.md
@@ -23,7 +23,7 @@ Three things:
- **Google Gemini** (multi-modal, long context)
- **Groq** (ultra-fast inference)
-Setup: Get API key → Set env var → Done
+Setup: Get API key → Add credential in Settings UI → Done
→ Go to **[AI Providers Guide](ai-providers.md)**
@@ -86,33 +86,24 @@ SURREAL_DATABASE=open_notebook
> The only thing that is critical to not miss is the hostname in the `SURREAL_URL`. Check what URL to use based on your deployment, [here](database.md).
-### AI Provider (API Key or URL)
+### AI Provider (Credentials)
-We need access to LLMs in order for the app to work. You can use any of the support AI Providers by adding their API Keys.
+We need access to LLMs in order for the app to work. AI provider credentials are configured via the **Settings UI**:
+
+1. Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in your environment (required for storing credentials)
+2. Start services
+3. Go to **Settings → API Keys → Add Credential**
+4. Select your provider, paste your API key
+5. **Test Connection** → **Discover Models** → **Register Models**
```
-OPENAI_API_KEY=sk-...
-ANTHROPIC_API_KEY=sk-ant-...
-GOOGLE_API_KEY=...
-OPENROUTER_API_KEY=...
+# Required in your .env or docker-compose.yml:
+OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
```
-Or, if you are planning to use only local providers, you can setup Ollama by configuring it's base URL. This will get you set and ready with text and embeddings in one go:
+> **Ollama users**: Add an Ollama credential in Settings → API Keys with the correct base URL. See [Ollama Setup](ollama.md) for network configuration help.
-```
-OLLAMA_BASE_URL=http://localhost:11434
-```
-
-> A lot of people screw up on the Ollama BASE URL by not knowing how to point to their Ollama installation. if you are having trouble connecting to Ollama, see [here](ollama.md).
-
-You can also use LM Studio locally if you prefer by using it as an OpenAI compatible endpoint.
-
-```
-OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
-OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:1234/v1
-```
-
-> For more installation on using OpenAI compatible endpoints, see [here](openai-compatible.md).
+> **LM Studio / OpenAI-Compatible**: Add an OpenAI-Compatible credential in Settings → API Keys. See [OpenAI-Compatible Guide](openai-compatible.md).
### API URL (If Behind Reverse Proxy)
@@ -132,22 +123,22 @@ Auto-detection works for most setups.
### Scenario 1: Docker on Localhost (Default)
```env
# In docker.env:
-OPENAI_API_KEY=sk-...
+OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
# Everything else uses defaults
-# Done!
+# Then configure AI provider in Settings → API Keys
```
### Scenario 2: Docker on Remote Server
```env
# In docker.env:
-OPENAI_API_KEY=sk-...
+OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
API_URL=http://your-server-ip:5055
```
### Scenario 3: Behind Reverse Proxy (Nginx/Cloudflare)
```env
# In docker.env:
-OPENAI_API_KEY=sk-...
+OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
API_URL=https://your-domain.com
# The reverse proxy handles HTTPS
```
@@ -155,16 +146,15 @@ API_URL=https://your-domain.com
### Scenario 4: Using Ollama Locally
```env
# In .env:
-OLLAMA_API_BASE=http://localhost:11434
-# No API key needed
+OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
+# Then add Ollama credential in Settings → API Keys
```
### Scenario 5: Using Azure OpenAI
```env
# In docker.env:
-AZURE_OPENAI_API_KEY=your-key
-AZURE_OPENAI_ENDPOINT=https://your-instance.openai.azure.com/
-AZURE_OPENAI_API_VERSION=2024-12-01-preview
+OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
+# Then add Azure OpenAI credential in Settings → API Keys
```
---
@@ -241,47 +231,45 @@ AZURE_OPENAI_API_VERSION=2024-12-01-preview
## How to Add Configuration
-### Method 1: Edit `.env` File (Development)
+### Method 1: Settings UI (For AI Provider Credentials)
+
+The recommended way to configure AI providers:
+
+```
+1. Open Open Notebook in your browser
+2. Go to Settings → API Keys
+3. Click "Add Credential"
+4. Select provider, enter API key
+5. Click Save, then Test Connection
+6. Click Discover Models → Register Models
+```
+
+No file editing, no restarts. Credentials stored securely (encrypted) in database.
+
+→ **[Full Guide: API Configuration](../3-USER-GUIDE/api-configuration.md)**
+
+### Method 2: Edit `.env` File (Infrastructure Settings)
+
+For database, network, and encryption key settings:
```bash
1. Open .env in your editor
-2. Find the section for your provider
-3. Uncomment and fill in your API key
-4. Save
-5. Restart services
+2. Set OPEN_NOTEBOOK_ENCRYPTION_KEY and database vars
+3. Save
+4. Restart services
```
-### Method 2: Set Docker Environment (Deployment)
+### Method 3: Set Docker Environment (Deployment)
```bash
# In docker-compose.yml:
services:
api:
environment:
- - OPENAI_API_KEY=sk-...
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-key
- API_URL=https://your-domain.com
```
-### Method 3: Export Environment Variables
-
-```bash
-# In your terminal:
-export OPENAI_API_KEY=sk-...
-export API_URL=https://your-domain.com
-
-# Then start services
-docker compose up
-```
-
-### Method 4: Use docker.env File
-
-```bash
-1. Create/edit docker.env
-2. Add your configuration
-3. docker-compose automatically loads it
-4. docker compose up
-```
-
---
## Verification
@@ -302,11 +290,11 @@ After configuration, verify it works:
| Mistake | Problem | Fix |
|---------|---------|-----|
-| Forget API key | Models not available | Add OPENAI_API_KEY (or your provider) |
+| No credential configured | Models not available | Add credential in Settings → API Keys |
+| Missing encryption key | Can't save credentials | Set OPEN_NOTEBOOK_ENCRYPTION_KEY |
| Wrong database URL | Can't start API | Check SURREAL_URL format |
| Expose port 5055 | "Can't connect to server" | Expose 5055 in docker-compose |
| Typo in env var | Settings ignored | Check spelling (case-sensitive!) |
-| Quote mismatch | Value cut off | Use quotes: OPENAI_API_KEY="sk-..." |
| Don't restart | Old config still used | Restart services after env changes |
---
@@ -332,9 +320,9 @@ Once configured:
## Summary
**Minimal configuration to run:**
-1. Choose an AI provider (or use Ollama locally)
-2. Set API key in .env or docker.env
-3. Start services
+1. Set `OPEN_NOTEBOOK_ENCRYPTION_KEY` in your environment
+2. Start services
+3. Add AI provider credential in Settings → API Keys
4. Done!
Everything else is optional optimization.
diff --git a/docs/5-CONFIGURATION/local-stt.md b/docs/5-CONFIGURATION/local-stt.md
index 3e6d313..4b5bac3 100644
--- a/docs/5-CONFIGURATION/local-stt.md
+++ b/docs/5-CONFIGURATION/local-stt.md
@@ -19,6 +19,12 @@ Run speech-to-text locally for free, private audio/video transcription using Ope
[Speaches](https://github.com/speaches-ai/speaches) is an open-source, OpenAI-compatible server that supports both TTS and STT. It uses [faster-whisper](https://github.com/SYSTRAN/faster-whisper) for transcription.
+> **💡 Ready-made Docker Compose files available:**
+> - **[docker-compose-speaches.yml](../../examples/docker-compose-speaches.yml)** - Speaches + Open Notebook
+> - **[docker-compose-full-local.yml](../../examples/docker-compose-full-local.yml)** - Speaches + Ollama (100% local setup)
+>
+> These include complete setup instructions and configuration examples. Just copy and run!
+
### Step 1: Create Docker Compose File
Create a folder and add `docker-compose.yml`:
@@ -67,15 +73,21 @@ You should see the transcribed text in the response.
### Step 4: Configure Open Notebook
-**Docker deployment:**
+**Via Settings UI (Recommended):**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential** → Select **OpenAI-Compatible**
+3. Enter base URL for STT: `http://host.docker.internal:8969/v1` (Docker) or `http://localhost:8969/v1` (local)
+4. Click **Save**, then **Test Connection**
+
+**Legacy (Deprecated) — Environment variables:**
```yaml
# In your Open Notebook docker-compose.yml
environment:
- OPENAI_COMPATIBLE_BASE_URL_STT=http://host.docker.internal:8969/v1
```
-**Local development:**
```bash
+# Local development
export OPENAI_COMPATIBLE_BASE_URL_STT=http://localhost:8969/v1
```
@@ -162,31 +174,23 @@ This is recommended if you have enough RAM/VRAM, as loading the model can take a
## Docker Networking
+When configuring your OpenAI-Compatible credential in **Settings → API Keys**, use the appropriate STT base URL for your setup:
+
### Open Notebook in Docker (macOS/Windows)
-```bash
-OPENAI_COMPATIBLE_BASE_URL_STT=http://host.docker.internal:8969/v1
-```
+**STT Base URL:** `http://host.docker.internal:8969/v1`
### Open Notebook in Docker (Linux)
-```bash
-# Option 1: Docker bridge IP
-OPENAI_COMPATIBLE_BASE_URL_STT=http://172.17.0.1:8969/v1
+**STT Base URL (Option 1 — Docker bridge IP):** `http://172.17.0.1:8969/v1`
-# Option 2: Host networking
-docker run --network host ...
-```
+**Option 2:** Use host networking mode (`docker run --network host ...`), then use: `http://localhost:8969/v1`
### Remote Server
Run Speaches on a different machine:
-```bash
-# On server, bind to all interfaces
-# Then in Open Notebook:
-OPENAI_COMPATIBLE_BASE_URL_STT=http://server-ip:8969/v1
-```
+**STT Base URL:** `http://server-ip:8969/v1` (replace with your server's IP)
---
@@ -330,13 +334,7 @@ docker stats speaches
## Using Both TTS and STT
-Speaches supports both TTS and STT in one server. Configure both:
-
-```bash
-# Same server for both
-OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8969/v1
-OPENAI_COMPATIBLE_BASE_URL_STT=http://localhost:8969/v1
-```
+Speaches supports both TTS and STT in one server. In **Settings → API Keys**, add a single **OpenAI-Compatible** credential and configure both the TTS and STT base URLs to point to the same Speaches server (e.g., `http://localhost:8969/v1`).
See **[Local TTS Setup](local-tts.md)** for TTS configuration.
@@ -356,7 +354,7 @@ Any OpenAI-compatible STT server works:
The key requirements:
1. Server implements `/v1/audio/transcriptions` endpoint
-2. Set `OPENAI_COMPATIBLE_BASE_URL_STT` to server URL
+2. Add an OpenAI-Compatible credential in **Settings → API Keys** with the STT base URL
3. Add model with provider `openai_compatible`
---
diff --git a/docs/5-CONFIGURATION/local-tts.md b/docs/5-CONFIGURATION/local-tts.md
index 20547b7..27784ec 100644
--- a/docs/5-CONFIGURATION/local-tts.md
+++ b/docs/5-CONFIGURATION/local-tts.md
@@ -19,6 +19,12 @@ Run text-to-speech locally for free, private podcast generation using OpenAI-com
[Speaches](https://github.com/speaches-ai/speaches) is an open-source, OpenAI-compatible TTS server.
+> **💡 Ready-made Docker Compose files available:**
+> - **[docker-compose-speaches.yml](../../examples/docker-compose-speaches.yml)** - Speaches + Open Notebook
+> - **[docker-compose-full-local.yml](../../examples/docker-compose-full-local.yml)** - Speaches + Ollama (100% local setup)
+>
+> These include complete setup instructions and configuration examples. Just copy and run!
+
### Step 1: Create Docker Compose File
Create a folder and add `docker-compose.yml`:
@@ -68,15 +74,21 @@ Play `test.mp3` to verify.
### Step 4: Configure Open Notebook
-**Docker deployment:**
+**Via Settings UI (Recommended):**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential** → Select **OpenAI-Compatible**
+3. Enter base URL for TTS: `http://host.docker.internal:8969/v1` (Docker) or `http://localhost:8969/v1` (local)
+4. Click **Save**, then **Test Connection**
+
+**Legacy (Deprecated) — Environment variables:**
```yaml
# In your Open Notebook docker-compose.yml
environment:
- OPENAI_COMPATIBLE_BASE_URL_TTS=http://host.docker.internal:8969/v1
```
-**Local development:**
```bash
+# Local development
export OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8969/v1
```
@@ -163,31 +175,23 @@ volumes:
## Docker Networking
+When configuring your OpenAI-Compatible credential in **Settings → API Keys**, use the appropriate TTS base URL for your setup:
+
### Open Notebook in Docker (macOS/Windows)
-```bash
-OPENAI_COMPATIBLE_BASE_URL_TTS=http://host.docker.internal:8969/v1
-```
+**TTS Base URL:** `http://host.docker.internal:8969/v1`
### Open Notebook in Docker (Linux)
-```bash
-# Option 1: Docker bridge IP
-OPENAI_COMPATIBLE_BASE_URL_TTS=http://172.17.0.1:8969/v1
+**TTS Base URL (Option 1 — Docker bridge IP):** `http://172.17.0.1:8969/v1`
-# Option 2: Host networking
-docker run --network host ...
-```
+**Option 2:** Use host networking mode (`docker run --network host ...`), then use: `http://localhost:8969/v1`
### Remote Server
Run Speaches on a different machine:
-```bash
-# On server, bind to all interfaces
-# Then in Open Notebook:
-OPENAI_COMPATIBLE_BASE_URL_TTS=http://server-ip:8969/v1
-```
+**TTS Base URL:** `http://server-ip:8969/v1` (replace with your server's IP)
---
@@ -327,7 +331,7 @@ docker stats speaches
Any OpenAI-compatible TTS server works. The key is:
1. Server implements `/v1/audio/speech` endpoint
-2. Set `OPENAI_COMPATIBLE_BASE_URL_TTS` to server URL
+2. Add an OpenAI-Compatible credential in **Settings → API Keys** with the TTS base URL
3. Add model with provider `openai_compatible`
---
diff --git a/docs/5-CONFIGURATION/ollama.md b/docs/5-CONFIGURATION/ollama.md
index 7818604..15904b1 100644
--- a/docs/5-CONFIGURATION/ollama.md
+++ b/docs/5-CONFIGURATION/ollama.md
@@ -38,41 +38,40 @@ ollama pull mxbai-embed-large # Best embedding model for Ollama
### 3. Configure Open Notebook
-**For local installation:**
-```bash
-export OLLAMA_API_BASE=http://localhost:11434
-```
+**Via Settings UI (Recommended):**
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential** → Select **Ollama**
+3. Enter the base URL (see [Network Configuration](#network-configuration-guide) below for correct URL)
+4. Click **Save**, then **Test Connection**
+5. Click **Discover Models** → **Register Models**
-**For Docker installation:**
+**Legacy (Deprecated) — Environment variables:**
```bash
+# For local installation:
+export OLLAMA_API_BASE=http://localhost:11434
+# For Docker installation:
export OLLAMA_API_BASE=http://host.docker.internal:11434
```
+> **Note**: The `OLLAMA_API_BASE` environment variable is deprecated. Configure Ollama via Settings → API Keys instead.
+
## Network Configuration Guide
-The `OLLAMA_API_BASE` environment variable tells Open Notebook where to find your Ollama server. The correct value depends on your deployment scenario:
+When adding an Ollama credential in **Settings → API Keys**, you need to enter the correct base URL. The correct URL depends on your deployment scenario:
### Scenario 1: Local Installation (Same Machine)
When both Open Notebook and Ollama run directly on your machine:
-```bash
-export OLLAMA_API_BASE=http://localhost:11434
-# or
-export OLLAMA_API_BASE=http://127.0.0.1:11434
-```
+**Base URL to enter in Settings → API Keys:** `http://localhost:11434`
-**Use `localhost` vs `127.0.0.1`:**
-- **localhost**: Recommended, works with most configurations
-- **127.0.0.1**: Use if you have DNS resolution issues with localhost
+Alternative: `http://127.0.0.1:11434` (use if you have DNS resolution issues with localhost)
### Scenario 2: Open Notebook in Docker, Ollama on Host
When Open Notebook runs in Docker but Ollama runs on your host machine:
-```bash
-export OLLAMA_API_BASE=http://host.docker.internal:11434
-```
+**Base URL to enter in Settings → API Keys:** `http://host.docker.internal:11434`
**⚠️ CRITICAL: Ollama must accept external connections:**
```bash
@@ -92,8 +91,6 @@ services:
# ... other settings ...
extra_hosts:
- "host.docker.internal:host-gateway"
- environment:
- - OLLAMA_API_BASE=http://host.docker.internal:11434
```
Without this, you'll get connection errors like:
@@ -115,9 +112,7 @@ httpcore.ConnectError: [Errno -2] Name or service not known
When both Open Notebook and Ollama run in the same Docker Compose stack:
-```bash
-export OLLAMA_API_BASE=http://ollama:11434
-```
+**Base URL to enter in Settings → API Keys:** `http://ollama:11434`
**Docker Compose Example:**
@@ -131,7 +126,7 @@ services:
- "8502:8502"
- "5055:5055"
environment:
- - OLLAMA_API_BASE=http://ollama:11434
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
volumes:
- ./notebook_data:/app/data
- ./surreal_data:/mydata
@@ -139,7 +134,7 @@ services:
- ollama
ollama:
- image: ollama/ollama:v1-latest
+ image: ollama/ollama:latest
ports:
- "11434:11434"
volumes:
@@ -161,10 +156,7 @@ volumes:
When Ollama runs on a different machine in your network:
-```bash
-export OLLAMA_API_BASE=http://192.168.1.100:11434
-# Replace 192.168.1.100 with your Ollama server's IP address
-```
+**Base URL to enter in Settings → API Keys:** `http://192.168.1.100:11434` (replace with your Ollama server's IP)
**Security Note:** Only use this in trusted networks. Ollama doesn't have built-in authentication.
@@ -175,11 +167,10 @@ If you've configured Ollama to use a different port:
```bash
# Start Ollama on custom port
OLLAMA_HOST=0.0.0.0:8080 ollama serve
-
-# Configure Open Notebook
-export OLLAMA_API_BASE=http://localhost:8080
```
+**Base URL to enter in Settings → API Keys:** `http://localhost:8080`
+
## Model Recommendations
### Language Models
@@ -301,10 +292,8 @@ qwen3:32b 030ee887880f 20 GB 9 days ago
curl http://localhost:11434/api/tags
```
-**Verify environment variable:**
-```bash
-echo $OLLAMA_API_BASE
-```
+**Verify credential is configured:**
+Check **Settings → API Keys** for an Ollama credential with the correct base URL.
**⚠️ IMPORTANT: Enable external connections (most common fix):**
```bash
@@ -382,8 +371,8 @@ netstat -tulpn | grep 11434
**Use custom port:**
```bash
OLLAMA_HOST=0.0.0.0:8080 ollama serve
-export OLLAMA_API_BASE=http://localhost:8080
```
+Then update the base URL in **Settings → API Keys** to `http://localhost:8080`
**6. "Failed to send message" in Chat**
@@ -442,18 +431,19 @@ services:
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
- - OLLAMA_API_BASE=http://host.docker.internal:11434
# ... rest of your config
```
+Then in **Settings → API Keys**, use base URL: `http://host.docker.internal:11434`
+
This maps `host.docker.internal` to your host machine's IP. macOS/Windows Docker Desktop does this automatically, but Linux requires explicit configuration.
**2. Host networking on Linux (alternative):**
```bash
# Use host networking if host.docker.internal doesn't work
docker run --network host lfnovo/open_notebook:v1-latest-single
-export OLLAMA_API_BASE=http://localhost:11434
```
+Then in **Settings → API Keys**, use base URL: `http://localhost:11434`
**3. Custom bridge network:**
```yaml
@@ -467,14 +457,14 @@ services:
networks:
- ollama_network
environment:
- - OLLAMA_API_BASE=http://ollama:11434
-
ollama:
networks:
- ollama_network
```
-**3. Firewall issues:**
+Then in **Settings → API Keys**, use base URL: `http://ollama:11434`
+
+**4. Firewall issues:**
```bash
# Allow Ollama port through firewall
sudo ufw allow 11434
@@ -550,8 +540,8 @@ export OLLAMA_MAX_QUEUE=512 # Request queue size
export OLLAMA_NUM_PARALLEL=4 # Parallel request handling
export OLLAMA_FLASH_ATTENTION=1 # Enable flash attention (if supported)
-# Open Notebook configuration
-export OLLAMA_API_BASE=http://localhost:11434
+# Open Notebook configuration (configure via Settings → API Keys instead)
+# OLLAMA_API_BASE=http://localhost:11434 # Deprecated — use Settings UI
```
### SSL Configuration (Self-Signed Certificates)
@@ -583,8 +573,8 @@ services:
image: lfnovo/open_notebook:v1-latest-single
pull_policy: always
environment:
- - OLLAMA_API_BASE=https://ollama.local:11434
- # Option 1: Custom CA bundle
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
+ # Option 1: Custom CA bundle (if Ollama uses self-signed SSL)
- ESPERANTO_SSL_CA_BUNDLE=/certs/ca-bundle.pem
# Option 2: Disable verification (dev only)
# - ESPERANTO_SSL_VERIFY=false
diff --git a/docs/5-CONFIGURATION/openai-compatible.md b/docs/5-CONFIGURATION/openai-compatible.md
index e16f803..220a216 100644
--- a/docs/5-CONFIGURATION/openai-compatible.md
+++ b/docs/5-CONFIGURATION/openai-compatible.md
@@ -40,12 +40,18 @@ Open Notebook can connect to any server using this format.
3. Download a model (e.g., Llama 3)
4. Start the local server (default: port 1234)
-### Step 2: Configure Environment
+### Step 2: Configure in Settings UI (Recommended)
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential** → Select **OpenAI-Compatible**
+3. Enter base URL: `http://host.docker.internal:1234/v1` (Docker) or `http://localhost:1234/v1` (local)
+4. API key: `lm-studio` (placeholder, LM Studio doesn't require one)
+5. Click **Save**, then **Test Connection**
+
+**Legacy (Deprecated) — Environment variables:**
```bash
-# For language models
export OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
-export OPENAI_COMPATIBLE_API_KEY=not-needed # LM Studio doesn't require key
+export OPENAI_COMPATIBLE_API_KEY=not-needed
```
### Step 3: Add Model in Open Notebook
@@ -60,7 +66,19 @@ export OPENAI_COMPATIBLE_API_KEY=not-needed # LM Studio doesn't require key
---
-## Environment Variables
+## Configuration via Settings UI
+
+The recommended way to configure OpenAI-compatible providers is through the Settings UI:
+
+1. Go to **Settings** → **API Keys**
+2. Click **Add Credential** → Select **OpenAI-Compatible**
+3. Enter your base URL and API key (if needed)
+4. Optionally configure per-service URLs for LLM, Embedding, TTS, and STT
+5. Click **Save**, then **Test Connection**
+
+## Legacy: Environment Variables (Deprecated)
+
+> **Deprecated**: These environment variables are deprecated. Use the Settings UI instead.
### Language Models (Chat)
@@ -73,7 +91,7 @@ OPENAI_COMPATIBLE_API_KEY=optional-api-key
```bash
OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:1234/v1
-OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=optional-api-key
+OPENAI_COMPATIBLE_API_KEY_EMBEDDING=optional-api-key
```
### Text-to-Speech
@@ -94,23 +112,18 @@ OPENAI_COMPATIBLE_API_KEY_STT=optional-api-key
## Docker Networking
-When Open Notebook runs in Docker and your compatible server runs on the host:
+When Open Notebook runs in Docker and your compatible server runs on the host, use the appropriate base URL when adding your credential in **Settings → API Keys**:
### macOS / Windows
-```bash
-OPENAI_COMPATIBLE_BASE_URL=http://host.docker.internal:1234/v1
-```
+**Base URL:** `http://host.docker.internal:1234/v1`
### Linux
-```bash
-# Option 1: Docker bridge IP
-OPENAI_COMPATIBLE_BASE_URL=http://172.17.0.1:1234/v1
+**Base URL (Option 1 — Docker bridge IP):** `http://172.17.0.1:1234/v1`
-# Option 2: Host networking mode
-docker run --network host ...
-```
+**Option 2:** Use host networking mode: `docker run --network host ...`
+Then use base URL: `http://localhost:1234/v1`
### Same Docker Network
@@ -119,8 +132,6 @@ docker run --network host ...
services:
open-notebook:
# ...
- environment:
- - OPENAI_COMPATIBLE_BASE_URL=http://lm-studio:1234/v1
lm-studio:
# your LM Studio container
@@ -128,6 +139,8 @@ services:
- "1234:1234"
```
+**Base URL in Settings → API Keys:** `http://lm-studio:1234/v1`
+
---
## Text Generation WebUI Setup
@@ -140,9 +153,7 @@ python server.py --api --listen
### Configure Open Notebook
-```bash
-OPENAI_COMPATIBLE_BASE_URL=http://localhost:5000/v1
-```
+In **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://localhost:5000/v1`
### Docker Compose Example
@@ -160,12 +171,12 @@ services:
open-notebook:
image: lfnovo/open_notebook:v1-latest-single
pull_policy: always
- environment:
- - OPENAI_COMPATIBLE_BASE_URL=http://text-gen:5000/v1
depends_on:
- text-gen
```
+Then in **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://text-gen:5000/v1`
+
---
## vLLM Setup
@@ -180,9 +191,7 @@ python -m vllm.entrypoints.openai.api_server \
### Configure Open Notebook
-```bash
-OPENAI_COMPATIBLE_BASE_URL=http://localhost:8000/v1
-```
+In **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://localhost:8000/v1`
### Docker Compose with GPU
@@ -206,12 +215,12 @@ services:
open-notebook:
image: lfnovo/open_notebook:v1-latest-single
pull_policy: always
- environment:
- - OPENAI_COMPATIBLE_BASE_URL=http://vllm:8000/v1
depends_on:
- vllm
```
+Then in **Settings → API Keys**, add an **OpenAI-Compatible** credential with base URL: `http://vllm:8000/v1`
+
---
## Adding Models in Open Notebook
@@ -306,8 +315,8 @@ Problem: 401 or authentication failed
Solutions:
1. Check if server requires API key
-2. Set OPENAI_COMPATIBLE_API_KEY
-3. Some servers need any non-empty key
+2. Set the API key in your credential (Settings → API Keys)
+3. Some servers need any non-empty key (use a placeholder like "not-needed")
```
### Timeout Errors
@@ -326,20 +335,14 @@ Solutions:
## Multiple Compatible Endpoints
-You can use different compatible servers for different purposes:
+You can use different compatible servers for different purposes. When adding an **OpenAI-Compatible** credential in **Settings → API Keys**, you can configure per-service URLs:
-```bash
-# Chat model from LM Studio
-OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
+- **LLM URL**: e.g., `http://localhost:1234/v1` (LM Studio)
+- **Embedding URL**: e.g., `http://localhost:8080/v1` (different server)
+- **TTS URL**: e.g., `http://localhost:8969/v1` (Speaches)
+- **STT URL**: e.g., `http://localhost:9000/v1` (Speaches)
-# Embeddings from different server
-OPENAI_COMPATIBLE_BASE_URL_EMBEDDING=http://localhost:8080/v1
-
-# TTS from Speaches
-OPENAI_COMPATIBLE_BASE_URL_TTS=http://localhost:8969/v1
-```
-
-Add each as a separate model in Open Notebook settings.
+Alternatively, add each as a separate credential with its own base URL.
---
diff --git a/docs/5-CONFIGURATION/reverse-proxy.md b/docs/5-CONFIGURATION/reverse-proxy.md
index e87f84b..da5c4ea 100644
--- a/docs/5-CONFIGURATION/reverse-proxy.md
+++ b/docs/5-CONFIGURATION/reverse-proxy.md
@@ -167,7 +167,7 @@ services:
container_name: open-notebook
environment:
- API_URL=https://notebook.example.com
- - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=${OPEN_NOTEBOOK_ENCRYPTION_KEY}
- OPEN_NOTEBOOK_PASSWORD=${OPEN_NOTEBOOK_PASSWORD}
volumes:
- ./notebook_data:/app/data
@@ -340,7 +340,7 @@ services:
pull_policy: always
environment:
- API_URL=https://api.notebook.example.com
- - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=${OPEN_NOTEBOOK_ENCRYPTION_KEY}
# Don't expose ports (nginx handles routing)
```
@@ -409,7 +409,7 @@ services:
image: lfnovo/open_notebook_api:v1-latest
pull_policy: always
environment:
- - OPENAI_API_KEY=${OPENAI_API_KEY}
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=${OPEN_NOTEBOOK_ENCRYPTION_KEY}
ports:
- "5055:5055"
depends_on:
@@ -616,7 +616,7 @@ You'll see which API URL is being used
curl https://your-domain.com/api/config
# Expected output:
-{"openai_api_key_set":true,"anthropic_api_key_set":false,...}
+{"status":"ok","credentials_configured":true,...}
```
**Step 3: Check Docker logs**
diff --git a/docs/5-CONFIGURATION/security.md b/docs/5-CONFIGURATION/security.md
index e3563d9..b7ffc22 100644
--- a/docs/5-CONFIGURATION/security.md
+++ b/docs/5-CONFIGURATION/security.md
@@ -4,6 +4,65 @@ Protect your Open Notebook deployment with password authentication and productio
---
+## API Key Encryption
+
+Open Notebook encrypts API keys stored in the database using Fernet symmetric encryption (AES-128-CBC with HMAC-SHA256).
+
+### Configuration Methods
+
+| Method | Documentation |
+|--------|---------------|
+| **Settings UI** | [API Configuration Guide](../3-USER-GUIDE/api-configuration.md) |
+| **Environment Variables** | This page (below) |
+
+### Setup
+
+Set the encryption key to any secret string:
+
+```bash
+# .env or docker.env
+OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-passphrase
+```
+
+Any string works — it will be securely derived via SHA-256 internally. Use a strong passphrase for production deployments.
+
+### Default Credentials
+
+| Setting | Default | Security Level |
+|---------|---------|----------------|
+| Password | `open-notebook-change-me` | Development only |
+| Encryption Key | **None** (must be configured) | Required for API key storage |
+
+**The encryption key has no default.** You must set `OPEN_NOTEBOOK_ENCRYPTION_KEY` before using the API key configuration feature. Without it, encrypting/decrypting API keys will fail.
+
+### Docker Secrets Support
+
+Both settings support Docker secrets via `_FILE` suffix:
+
+```yaml
+environment:
+ - OPEN_NOTEBOOK_PASSWORD_FILE=/run/secrets/app_password
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE=/run/secrets/encryption_key
+```
+
+### Security Notes
+
+| Scenario | Behavior |
+|----------|----------|
+| Key configured | API keys encrypted with your key |
+| No key configured | Encryption/decryption will fail (key is required) |
+| Key changed | Old encrypted keys become unreadable |
+| Legacy data | Unencrypted keys still work (graceful fallback) |
+
+### Key Management
+
+- **Keep secret**: Never commit the encryption key to version control
+- **Backup securely**: Store the key separately from database backups
+- **No rotation yet**: Changing the key requires re-saving all API keys
+- **Per-deployment**: Each instance should have its own encryption key
+
+---
+
## When to Use Password Protection
### Use it for:
@@ -29,7 +88,7 @@ services:
image: lfnovo/open_notebook:v1-latest-single
pull_policy: always
environment:
- - OPENAI_API_KEY=sk-...
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-encryption-key
- OPEN_NOTEBOOK_PASSWORD=your_secure_password
# ... rest of config
```
@@ -38,10 +97,12 @@ Or using environment file:
```bash
# docker.env
-OPENAI_API_KEY=sk-...
+OPEN_NOTEBOOK_ENCRYPTION_KEY=your-secret-encryption-key
OPEN_NOTEBOOK_PASSWORD=your_secure_password
```
+> **Important**: The encryption key is **required** for credential storage. Without it, you cannot save AI provider credentials via the Settings UI. If you change or lose the encryption key, all stored credentials become unreadable.
+
### Development Setup
```bash
diff --git a/docs/6-TROUBLESHOOTING/ai-chat-issues.md b/docs/6-TROUBLESHOOTING/ai-chat-issues.md
index cff8737..1f8376b 100644
--- a/docs/6-TROUBLESHOOTING/ai-chat-issues.md
+++ b/docs/6-TROUBLESHOOTING/ai-chat-issues.md
@@ -63,44 +63,36 @@ ollama list
**Symptom:** Settings → Models shows empty, or "No models configured"
-**Cause:** Missing or invalid API key
+**Cause:** No credential configured, or credential has invalid API key
**Solutions:**
-### Solution 1: Add API Key
-```bash
-# Check .env has your API key:
-cat .env | grep -i "OPENAI\|ANTHROPIC\|GOOGLE"
-
-# Should see something like:
-# OPENAI_API_KEY=sk-proj-...
-
-# If missing, add it:
-OPENAI_API_KEY=sk-proj-your-key-here
-
-# Save and restart:
-docker compose restart api
-
-# Wait 10 seconds, then refresh browser
+### Solution 1: Add Credential via Settings UI
+```
+1. Go to Settings → API Keys
+2. Click "Add Credential"
+3. Select your provider (e.g., OpenAI, Anthropic, Google)
+4. Enter your API key
+5. Click Save, then Test Connection
+6. Click Discover Models → Register Models
+7. Go to Settings → Models to verify
```
### Solution 2: Check Key is Valid
-```bash
-# Test API key directly:
-curl https://api.openai.com/v1/models \
- -H "Authorization: Bearer sk-proj-..."
-
-# Should return list of models
-# If error: key is invalid
+```
+1. Go to Settings → API Keys
+2. Click "Test Connection" on your credential
+3. If it shows "Invalid API key":
+ - Get a fresh key from the provider's website
+ - Delete the credential and create a new one
```
### Solution 3: Switch Provider
-```bash
-# Try a different provider:
-# Remove: OPENAI_API_KEY
-# Add: ANTHROPIC_API_KEY=sk-ant-...
-
-# Restart and check Settings → Models
+```
+1. Go to Settings → API Keys
+2. Add a credential for a different provider
+3. Test Connection → Discover Models → Register Models
+4. Go to Settings → Models to select the new provider's models
```
---
@@ -109,47 +101,42 @@ curl https://api.openai.com/v1/models \
**Symptom:** Error when trying to chat: "Invalid API key"
-**Cause:** API key wrong, expired, or revoked
+**Cause:** Credential has wrong, expired, or revoked API key
**Solutions:**
-### Step 1: Verify Key Format
-```bash
-# OpenAI: Should start with sk-proj-
-# Anthropic: Should start with sk-ant-
-# Google: Should be AIzaSy...
-
-# Check in .env:
-cat .env | grep OPENAI_API_KEY
+### Step 1: Test Your Credential
+```
+1. Go to Settings → API Keys
+2. Click "Test Connection" on your credential
+3. If it fails, proceed to Step 2
```
### Step 2: Get Fresh Key
-```bash
-# Go to provider's dashboard:
-# - OpenAI: https://platform.openai.com/api-keys
-# - Anthropic: https://console.anthropic.com/
-# - Google: https://aistudio.google.com/app/apikey
+```
+Go to provider's dashboard:
+- OpenAI: https://platform.openai.com/api-keys (starts with sk-proj-)
+- Anthropic: https://console.anthropic.com/ (starts with sk-ant-)
+- Google: https://aistudio.google.com/app/apikey (starts with AIzaSy)
-# Generate new key
-# Copy exactly (no extra spaces)
+Generate new key and copy exactly (no extra spaces)
```
-### Step 3: Update .env
-```bash
-# Edit .env:
-OPENAI_API_KEY=sk-proj-new-key-here
-# No quotes needed, no spaces
-
-# Save and restart:
-docker compose restart api
+### Step 3: Update Credential
+```
+1. Go to Settings → API Keys
+2. Delete the old credential
+3. Click "Add Credential" → select provider
+4. Paste the new key
+5. Click Save, then Test Connection
+6. Re-discover and register models if needed
```
### Step 4: Verify in UI
```
-1. Open Open Notebook
-2. Go to Settings → Models
-3. Select your provider
-4. Should show available models
+1. Go to Settings → Models
+2. Verify models are available
+3. Try a test chat
```
---
diff --git a/docs/6-TROUBLESHOOTING/connection-issues.md b/docs/6-TROUBLESHOOTING/connection-issues.md
index b8c47f1..1d10bb8 100644
--- a/docs/6-TROUBLESHOOTING/connection-issues.md
+++ b/docs/6-TROUBLESHOOTING/connection-issues.md
@@ -428,9 +428,9 @@ ESPERANTO_SSL_VERIFY=false
### Solution 3: Use HTTP Instead
If services are on a trusted local network, HTTP is acceptable:
-```bash
-# Change endpoint from https:// to http://
-OPENAI_COMPATIBLE_BASE_URL=http://localhost:1234/v1
+```
+Change the base URL in your credential (Settings → API Keys) from https:// to http://
+Example: http://localhost:1234/v1
```
> **Security Note:** Disabling SSL verification exposes you to man-in-the-middle attacks. Always prefer custom CA bundle or HTTP on trusted networks.
diff --git a/docs/6-TROUBLESHOOTING/quick-fixes.md b/docs/6-TROUBLESHOOTING/quick-fixes.md
index 16ae951..fe5bc55 100644
--- a/docs/6-TROUBLESHOOTING/quick-fixes.md
+++ b/docs/6-TROUBLESHOOTING/quick-fixes.md
@@ -39,31 +39,23 @@ docker compose restart
**Symptom:** Settings → Models shows "No models available"
-**Cause:** API key missing, wrong, or not set
+**Cause:** No credential configured, or credential has invalid API key
**Solution (1 minute):**
-```bash
-# Step 1: Check your .env has API key
-cat .env | grep OPENAI_API_KEY
-
-# Step 2: Verify it's correct (from https://platform.openai.com/api-keys)
-# Should look like: sk-proj-xxx...
-
-# Step 3: Restart services
-docker compose restart api
-
-# Step 4: Wait 10 seconds, then refresh browser
-# Go to Settings → Models
-
-# If still no models:
-# Check logs for error
-docker compose logs api | grep -i "api key\|error"
+```
+1. Go to Settings → API Keys
+2. If no credential exists, click "Add Credential" and add one
+3. If a credential exists, click "Test Connection"
+4. If test fails, delete and re-create with correct key
+5. After test passes, click "Discover Models" → "Register Models"
+6. Go to Settings → Models to verify models appear
```
**If still broken:**
- Make sure key has no extra spaces
- Generate a fresh key from provider dashboard
+- Check that `OPEN_NOTEBOOK_ENCRYPTION_KEY` is set in docker-compose.yml
- See [AI & Chat Issues](ai-chat-issues.md)
---
diff --git a/docs/7-DEVELOPMENT/api-reference.md b/docs/7-DEVELOPMENT/api-reference.md
index 5cfa171..451f674 100644
--- a/docs/7-DEVELOPMENT/api-reference.md
+++ b/docs/7-DEVELOPMENT/api-reference.md
@@ -75,6 +75,16 @@ Instead of memorizing endpoints, use the interactive API docs:
- `GET /models/defaults` - Current defaults
- `POST /models/config` - Set defaults
+**Credentials** - Manage AI provider credentials
+- `GET/POST /credentials` - List and create credentials
+- `GET/PUT/DELETE /credentials/{id}` - CRUD operations
+- `POST /credentials/{id}/test` - Test connection
+- `POST /credentials/{id}/discover` - Discover models from provider
+- `POST /credentials/{id}/register-models` - Register discovered models
+- `GET /credentials/status` - Provider status overview
+- `GET /credentials/env-status` - Environment variable status
+- `POST /credentials/migrate-from-env` - Migrate env vars to credentials
+
**Health & Status**
- `GET /health` - Health check
- `GET /commands/{id}` - Track async operations
diff --git a/docs/7-DEVELOPMENT/development-setup.md b/docs/7-DEVELOPMENT/development-setup.md
index e0bbc2f..6a537be 100644
--- a/docs/7-DEVELOPMENT/development-setup.md
+++ b/docs/7-DEVELOPMENT/development-setup.md
@@ -53,11 +53,8 @@ SURREAL_PASSWORD=password
SURREAL_NAMESPACE=open_notebook
SURREAL_DATABASE=development
-# AI Providers (add your API keys)
-OPENAI_API_KEY=sk-...
-ANTHROPIC_API_KEY=sk-ant-...
-GOOGLE_API_KEY=AI...
-GROQ_API_KEY=gsk-...
+# Credential encryption (required for storing API keys)
+OPEN_NOTEBOOK_ENCRYPTION_KEY=my-dev-secret-key
# Application
APP_PASSWORD= # Optional password protection
@@ -65,10 +62,17 @@ DEBUG=true
LOG_LEVEL=DEBUG
```
-### AI Provider Keys
+### AI Provider Configuration
-You'll need at least one AI provider. Popular options:
+After starting the API and frontend, configure your AI provider via the Settings UI:
+1. Open **http://localhost:3000** → **Settings** → **API Keys**
+2. Click **Add Credential** → Select your provider
+3. Enter your API key (get from provider dashboard)
+4. Click **Save**, then **Test Connection**
+5. Click **Discover Models** → **Register Models**
+
+Popular providers:
- **OpenAI** - https://platform.openai.com/api-keys
- **Anthropic (Claude)** - https://console.anthropic.com/
- **Google** - https://ai.google.dev/
@@ -77,6 +81,8 @@ You'll need at least one AI provider. Popular options:
For local development, you can also use:
- **Ollama** - Run locally without API keys (see "Local Ollama" below)
+> **Note:** API key environment variables (e.g., `OPENAI_API_KEY`) are deprecated. Use the Settings UI to manage credentials instead.
+
## Step 4: Start SurrealDB
### Option A: Using Docker (Recommended)
@@ -363,12 +369,13 @@ For testing with local AI models:
# Pull a model (e.g., Mistral 7B)
ollama pull mistral
-
-# Add to .env
-OLLAMA_BASE_URL=http://localhost:11434
```
-Then in your code, you can use Ollama through the Esperanto library.
+Then configure via the Settings UI:
+1. Go to **Settings** → **API Keys** → **Add Credential** → **Ollama**
+2. Enter base URL: `http://localhost:11434`
+3. Click **Save**, then **Test Connection**
+4. Click **Discover Models** → **Register Models**
## Optional: Docker Development Environment
diff --git a/docs/SECURITY_REVIEW.md b/docs/SECURITY_REVIEW.md
new file mode 100644
index 0000000..449b167
--- /dev/null
+++ b/docs/SECURITY_REVIEW.md
@@ -0,0 +1,96 @@
+# Security Review - API Configuration UI
+
+## Date: 2026-01-27 (Updated: 2026-01-28)
+## Reviewer: Security Audit
+
+---
+
+## Summary
+
+Security review of the API key management implementation for Open Notebook. The implementation uses a database-first approach with environment variable fallback.
+
+---
+
+## Encryption
+
+| Item | Status | Notes |
+|------|--------|-------|
+| Fernet encryption implemented | PASS | `open_notebook/utils/encryption.py` uses AES-128-CBC + HMAC-SHA256 |
+| Keys encrypted before DB storage | PASS | `encrypt_value()` applied on save |
+| Keys decrypted only when needed | PASS | `decrypt_value()` called when reading |
+| Encryption key required | PASS | No default key; ValueError if not configured |
+| Docker secrets support | PASS | `_FILE` suffix pattern supported |
+| Documented in .env.example | PASS | Encryption key documented |
+
+---
+
+## API Security
+
+| Item | Status | Notes |
+|------|--------|-------|
+| Test endpoint implemented | PASS | `connection_tester.py` validates keys |
+| Test doesn't expose keys | PASS | Only returns success/failure |
+| Error messages don't leak info | PASS | Generic error messages |
+| URL validation for SSRF | PASS | Blocks private IPs (except Ollama) |
+| Rate limiting | NOT IMPL | Future enhancement |
+
+---
+
+## Frontend Security
+
+| Item | Status | Notes |
+|------|--------|-------|
+| No keys in localStorage | PASS | Keys only in React state during entry |
+| Keys masked in UI | PASS | Shows `************` placeholder |
+| No keys in console.log | PASS | No logging of sensitive data |
+| autocomplete attributes | PARTIAL | Some forms missing autocomplete="off" |
+
+---
+
+## Authentication
+
+| Item | Status | Notes |
+|------|--------|-------|
+| Password protection | PASS | Bearer token authentication |
+| Default password | PASS | "open-notebook-change-me" when not set |
+| Docker secrets support | PASS | `_FILE` suffix for password |
+| Security warnings | PASS | Logged when using defaults |
+
+---
+
+## Files Reviewed
+
+| Component | Path | Status |
+|-----------|------|--------|
+| Encryption | `open_notebook/utils/encryption.py` | PASS |
+| Credential model | `open_notebook/domain/credential.py` | PASS |
+| Credentials router | `api/routers/credentials.py` | PASS |
+| Key provider | `open_notebook/ai/key_provider.py` | PASS |
+| Connection tester | `open_notebook/ai/connection_tester.py` | PASS |
+| Auth middleware | `api/auth.py` | PASS |
+| Frontend forms | `frontend/src/components/settings/*.tsx` | PASS |
+| Environment example | `.env.example` | PASS |
+
+---
+
+## Remaining Recommendations
+
+### Future Improvements
+
+1. **Rate limiting** - Add rate limiting on `/credentials/*` endpoints
+2. **Autocomplete attributes** - Add `autocomplete="new-password"` to all password inputs
+3. **Show last 4 characters** - Display `********xxxx` format for key identification
+4. **Audit logging** - Log API key changes with timestamps
+
+---
+
+## Conclusion
+
+The API Configuration UI implementation meets security requirements:
+
+- API keys encrypted at rest using Fernet (key must be explicitly configured)
+- Keys never returned to frontend
+- URL validation prevents SSRF attacks
+- Docker secrets supported for production deployments
+
+**Review Status: PASS**
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..dc2a7fd
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,163 @@
+# Docker Compose Examples
+
+This folder contains different `docker-compose.yml` configurations for various use cases.
+
+## 📋 Available Examples
+
+### `docker-compose-full-local.yml` - 100% Local AI (No Cloud APIs) 🌟
+**Use this if:** You want complete privacy with zero external API dependencies
+
+**Features:**
+- **Ollama**: Local LLM and embeddings (mistral, llama, etc.)
+- **Speaches**: Local TTS (text-to-speech) and STT (speech-to-text)
+- Everything runs on your machine - nothing sent to cloud
+- Perfect for privacy, offline work, or air-gapped environments
+
+**Setup:**
+1. Copy to your project folder as `docker-compose.yml`
+2. Run: `docker compose up -d`
+3. Download models (see file comments for commands)
+4. Configure all providers in UI (detailed instructions in file)
+
+**Requirements:**
+- Minimum: 8GB RAM, 20GB disk, 4 CPU cores
+- Recommended: 16GB+ RAM, NVIDIA GPU (8GB+ VRAM), 50GB disk
+
+**Documentation:**
+- [Local TTS Guide](../docs/5-CONFIGURATION/local-tts.md)
+- [Local STT Guide](../docs/5-CONFIGURATION/local-stt.md)
+
+---
+
+### `docker-compose-speaches.yml` - Local Speech Processing
+**Use this if:** You want free TTS/STT but use cloud LLMs
+
+**Features:**
+- **Speaches**: Local text-to-speech and speech-to-text
+- Use with cloud LLM providers (OpenAI, Anthropic, etc.)
+- Great for podcast generation without TTS API costs
+- Private audio processing
+
+**Setup:**
+1. Copy to your project folder as `docker-compose.yml`
+2. Run: `docker compose up -d`
+3. Download speech models (see file for commands)
+4. Configure cloud LLM + local Speaches in UI
+
+**Documentation:**
+- [Local TTS Guide](../docs/5-CONFIGURATION/local-tts.md)
+- [Local STT Guide](../docs/5-CONFIGURATION/local-stt.md)
+
+---
+
+### `docker-compose-ollama.yml` - Free Local AI with Ollama
+**Use this if:** You want to run AI models locally without API costs
+
+**Features:**
+- Includes Ollama service for local AI models
+- No external API keys needed (for LLM and embeddings)
+- Full privacy - everything runs on your machine
+- Great for testing or privacy-focused deployments
+
+**Setup:**
+1. Copy to your project folder as `docker-compose.yml`
+2. Run: `docker compose up -d`
+3. Pull a model: `docker exec open_notebook-ollama-1 ollama pull mistral`
+4. Configure in UI: Settings → API Keys → Add Ollama (URL: `http://ollama:11434`)
+
+**Recommended models:**
+- **LLM**: `mistral`, `llama3.1`, `qwen2.5`
+- **Embeddings**: `nomic-embed-text`, `mxbai-embed-large`
+
+---
+
+### `docker-compose-single.yml` - Single Container (Deprecated)
+**Use this if:** You need all services in one container (not recommended)
+
+**⚠️ Deprecated:** We recommend using the standard multi-container setup (`docker-compose.yml` in root) for better reliability and easier troubleshooting.
+
+**Features:**
+- Single container includes SurrealDB, API, and Frontend
+- Simpler for very constrained environments
+- Less flexible for debugging and scaling
+
+---
+
+### `docker-compose-dev.yml` - Development Setup
+**Use this if:** You're contributing to Open Notebook or developing custom features
+
+**Features:**
+- Hot-reload for code changes
+- Separate backend and frontend services
+- Build from source instead of using pre-built images
+- Includes development tools and debugging
+
+**Prerequisites:**
+- Python 3.11+
+- Node.js 18+
+- uv (Python package manager)
+
+**Setup:**
+See [Development Guide](../docs/7-DEVELOPMENT/index.md)
+
+---
+
+## 🔄 How to Use These Examples
+
+1. **Choose** the example that fits your use case
+2. **Copy** the file to your project folder:
+ ```bash
+ cp examples/docker-compose-ollama.yml docker-compose.yml
+ ```
+3. **Edit** the `OPEN_NOTEBOOK_ENCRYPTION_KEY` value
+4. **Run** the services:
+ ```bash
+ docker compose up -d
+ ```
+
+---
+
+## 💡 Need a Custom Setup?
+
+You can combine features from multiple examples. Common customizations:
+
+### Add Ollama to Standard Setup
+Add this to the main `docker-compose.yml`:
+
+```yaml
+ ollama:
+ image: ollama/ollama:latest
+ ports:
+ - "11434:11434"
+ volumes:
+ - ollama_models:/root/.ollama
+ restart: always
+
+volumes:
+ ollama_models:
+```
+
+### Add Reverse Proxy
+See [Reverse Proxy Guide](../docs/5-CONFIGURATION/reverse-proxy.md)
+
+### Add Basic Auth
+Add to `open_notebook` service environment:
+```yaml
+- BASIC_AUTH_USERNAME=admin
+- BASIC_AUTH_PASSWORD=your-secure-password
+```
+
+---
+
+## 📚 Documentation
+
+- [Installation Guide](../docs/1-INSTALLATION/index.md)
+- [Configuration Reference](../docs/5-CONFIGURATION/environment-reference.md)
+- [Troubleshooting](../docs/6-TROUBLESHOOTING/index.md)
+
+---
+
+## 🆘 Need Help?
+
+- **Discord**: [Join our community](https://discord.gg/37XJPXfz2w)
+- **Issues**: [GitHub Issues](https://github.com/lfnovo/open-notebook/issues)
diff --git a/docker-compose.dev.yml b/examples/docker-compose-dev.yml
similarity index 100%
rename from docker-compose.dev.yml
rename to examples/docker-compose-dev.yml
diff --git a/examples/docker-compose-full-local.yml b/examples/docker-compose-full-local.yml
new file mode 100644
index 0000000..86d85fe
--- /dev/null
+++ b/examples/docker-compose-full-local.yml
@@ -0,0 +1,197 @@
+# Docker Compose - 100% Local AI Setup
+#
+# This is the complete privacy-focused setup with NO external APIs needed:
+# - Ollama: Local LLM and embeddings (mistral, llama, nomic-embed, etc.)
+# - Speaches: Local TTS (text-to-speech) and STT (speech-to-text)
+# - Open Notebook: Your research assistant
+# - SurrealDB: Local database
+#
+# Perfect for:
+# - Complete privacy (nothing leaves your machine)
+# - Offline work
+# - No API costs
+# - Air-gapped environments
+# - Testing and development
+#
+# Usage:
+# 1. Copy this file to your project folder as docker-compose.yml
+# 2. Change OPEN_NOTEBOOK_ENCRYPTION_KEY below
+# 3. Run: docker compose up -d
+# 4. Pull models (see instructions below)
+# 5. Configure providers in UI
+#
+# Full documentation:
+# - Ollama setup: https://github.com/lfnovo/open-notebook/blob/main/examples/README.md
+# - TTS setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-tts.md
+# - STT setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-stt.md
+
+services:
+ surrealdb:
+ image: surrealdb/surrealdb:v2
+ command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
+ user: root
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./surreal_data:/mydata
+ environment:
+ - SURREAL_EXPERIMENTAL_GRAPHQL=true
+ restart: always
+ pull_policy: always
+
+ ollama:
+ image: ollama/ollama:latest
+ ports:
+ - "11434:11434"
+ volumes:
+ - ollama_models:/root/.ollama
+ restart: always
+ pull_policy: always
+ # For GPU acceleration (NVIDIA), add:
+ # deploy:
+ # resources:
+ # reservations:
+ # devices:
+ # - driver: nvidia
+ # count: 1
+ # capabilities: [gpu]
+
+ speaches:
+ image: ghcr.io/speaches-ai/speaches:latest-cpu
+ container_name: speaches
+ ports:
+ - "8969:8000"
+ volumes:
+ - hf-hub-cache:/home/ubuntu/.cache/huggingface/hub
+ restart: unless-stopped
+ # For GPU acceleration, use: ghcr.io/speaches-ai/speaches:latest-cuda
+ # and add GPU device mapping (see docs)
+
+ open_notebook:
+ image: lfnovo/open_notebook:v1-latest
+ ports:
+ - "8502:8502"
+ - "5055:5055"
+ environment:
+ # REQUIRED: Change this to your own secret string
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
+
+ # Database connection
+ - SURREAL_URL=ws://surrealdb:8000/rpc
+ - SURREAL_USER=root
+ - SURREAL_PASSWORD=root
+ - SURREAL_NAMESPACE=open_notebook
+ - SURREAL_DATABASE=open_notebook
+
+ # Ollama connection (optional, can also configure via UI)
+ - OLLAMA_BASE_URL=http://ollama:11434
+ volumes:
+ - ./notebook_data:/app/data
+ depends_on:
+ - surrealdb
+ - ollama
+ - speaches
+ restart: always
+ pull_policy: always
+
+volumes:
+ ollama_models:
+ hf-hub-cache:
+
+# ==========================================
+# AFTER STARTING: Download Models
+# ==========================================
+#
+# Ollama Models (LLM):
+# docker exec open_notebook-ollama-1 ollama pull mistral
+# docker exec open_notebook-ollama-1 ollama pull llama3.1
+# docker exec open_notebook-ollama-1 ollama pull qwen2.5
+#
+# Ollama Models (Embeddings):
+# docker exec open_notebook-ollama-1 ollama pull nomic-embed-text
+# docker exec open_notebook-ollama-1 ollama pull mxbai-embed-large
+#
+# Speaches (TTS):
+# docker compose exec speaches uv tool run speaches-cli model download speaches-ai/Kokoro-82M-v1.0-ONNX
+#
+# Speaches (STT):
+# docker compose exec speaches uv tool run speaches-cli model download Systran/faster-whisper-small
+#
+# ==========================================
+# CONFIGURATION IN OPEN NOTEBOOK
+# ==========================================
+#
+# 1. Configure Ollama:
+# - Go to Settings → API Keys
+# - Add Credential → Select "Ollama"
+# - Base URL: http://ollama:11434
+# - Save → Test Connection → Discover Models → Register Models
+#
+# 2. Configure Speaches (TTS/STT):
+# - Go to Settings → API Keys
+# - Add Credential → Select "OpenAI-Compatible"
+# - Name: "Local Speaches"
+# - Base URL for TTS: http://host.docker.internal:8969/v1 (macOS/Windows)
+# or: http://172.17.0.1:8969/v1 (Linux)
+# - Base URL for STT: (same as TTS)
+# - Save → Test Connection
+#
+# 3. Discover Speech Models:
+# - In the Speaches credential you just created, click Discover Models
+# - Select and register the models you need (e.g. TTS and STT)
+# - If models aren't discovered automatically, add them manually:
+# * TTS: speaches-ai/Kokoro-82M-v1.0-ONNX
+# * STT: Systran/faster-whisper-small
+#
+# ==========================================
+# RECOMMENDED MODELS
+# ==========================================
+#
+# For LLM (choose based on your hardware):
+# - Fast: mistral (7B), qwen2.5 (7B)
+# - Balanced: llama3.1 (8B)
+# - Best quality: qwen2.5 (14B+), llama3.1 (70B) - requires powerful GPU
+#
+# For Embeddings:
+# - nomic-embed-text (recommended, 137M params)
+# - mxbai-embed-large (334M params, better quality)
+#
+# For TTS:
+# - speaches-ai/Kokoro-82M-v1.0-ONNX (good quality, fast)
+#
+# For STT (Whisper):
+# - faster-whisper-small (balanced, ~500MB)
+# - faster-whisper-base (faster, less accurate)
+# - faster-whisper-large-v3 (best quality, slower, ~3GB)
+#
+# ==========================================
+# HARDWARE REQUIREMENTS
+# ==========================================
+#
+# Minimum (CPU only):
+# - 8 GB RAM
+# - 20 GB disk space
+# - 4 CPU cores
+#
+# Recommended (with GPU):
+# - 16+ GB RAM
+# - 8+ GB VRAM (NVIDIA GPU)
+# - 50 GB disk space
+# - 8+ CPU cores
+#
+# ==========================================
+# COST COMPARISON
+# ==========================================
+#
+# Local (this setup):
+# - Cost: $0 (after hardware)
+# - Privacy: 100% private
+# - Speed: Depends on hardware
+#
+# Cloud (OpenAI + ElevenLabs):
+# - LLM: ~$0.01-0.10 per 1K tokens
+# - Embeddings: ~$0.0001 per 1K tokens
+# - TTS: ~$0.015 per minute
+# - STT: ~$0.006 per minute
+# - Privacy: Data sent to providers
+# - Speed: Usually faster
diff --git a/examples/docker-compose-ollama.yml b/examples/docker-compose-ollama.yml
new file mode 100644
index 0000000..3d22a77
--- /dev/null
+++ b/examples/docker-compose-ollama.yml
@@ -0,0 +1,63 @@
+# Docker Compose with Ollama (Free Local AI)
+#
+# This setup includes Ollama for running local AI models without API costs.
+# Great for privacy-focused deployments or testing without cloud dependencies.
+#
+# Usage:
+# 1. Copy this file to your project folder as docker-compose.yml
+# 2. Change OPEN_NOTEBOOK_ENCRYPTION_KEY below
+# 3. Run: docker compose up -d
+# 4. Pull a model: docker exec open_notebook-ollama-1 ollama pull mistral
+# 5. Configure Ollama in UI: Settings → API Keys → Add Ollama (URL: http://ollama:11434)
+
+services:
+ surrealdb:
+ image: surrealdb/surrealdb:v2
+ command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
+ user: root
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./surreal_data:/mydata
+ environment:
+ - SURREAL_EXPERIMENTAL_GRAPHQL=true
+ restart: always
+ pull_policy: always
+
+ ollama:
+ image: ollama/ollama:latest
+ ports:
+ - "11434:11434"
+ volumes:
+ - ollama_models:/root/.ollama
+ restart: always
+ pull_policy: always
+
+ open_notebook:
+ image: lfnovo/open_notebook:v1-latest
+ ports:
+ - "8502:8502"
+ - "5055:5055"
+ environment:
+ # REQUIRED: Change this to your own secret string
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
+
+ # Database connection
+ - SURREAL_URL=ws://surrealdb:8000/rpc
+ - SURREAL_USER=root
+ - SURREAL_PASSWORD=root
+ - SURREAL_NAMESPACE=open_notebook
+ - SURREAL_DATABASE=open_notebook
+
+ # Ollama connection
+ - OLLAMA_BASE_URL=http://ollama:11434
+ volumes:
+ - ./notebook_data:/app/data
+ depends_on:
+ - surrealdb
+ - ollama
+ restart: always
+ pull_policy: always
+
+volumes:
+ ollama_models:
diff --git a/docker-compose.single.yml b/examples/docker-compose-single.yml
similarity index 100%
rename from docker-compose.single.yml
rename to examples/docker-compose-single.yml
diff --git a/examples/docker-compose-speaches.yml b/examples/docker-compose-speaches.yml
new file mode 100644
index 0000000..66834c1
--- /dev/null
+++ b/examples/docker-compose-speaches.yml
@@ -0,0 +1,125 @@
+# Docker Compose with Speaches (Local TTS/STT)
+#
+# This setup includes Speaches for free, private speech processing:
+# - Text-to-Speech (TTS): Generate podcast audio locally
+# - Speech-to-Text (STT): Transcribe audio/video content locally
+#
+# Why Speaches?
+# - Free: No per-minute/per-character costs
+# - Private: Audio never leaves your machine
+# - Offline: Works without internet
+# - OpenAI-compatible: Drop-in replacement for OpenAI TTS/STT
+#
+# Usage:
+# 1. Copy this file to your project folder as docker-compose.yml
+# 2. Change OPEN_NOTEBOOK_ENCRYPTION_KEY below
+# 3. Run: docker compose up -d
+# 4. Download models (see instructions below)
+# 5. Configure in UI: Settings → API Keys → Add OpenAI-Compatible
+#
+# Full documentation:
+# - TTS setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-tts.md
+# - STT setup: https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/local-stt.md
+
+services:
+ surrealdb:
+ image: surrealdb/surrealdb:v2
+ command: start --log info --user root --pass root rocksdb:/mydata/mydatabase.db
+ user: root
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./surreal_data:/mydata
+ environment:
+ - SURREAL_EXPERIMENTAL_GRAPHQL=true
+ restart: always
+ pull_policy: always
+
+ speaches:
+ image: ghcr.io/speaches-ai/speaches:latest-cpu
+ container_name: speaches
+ ports:
+ - "8969:8000"
+ volumes:
+ - hf-hub-cache:/home/ubuntu/.cache/huggingface/hub
+ restart: unless-stopped
+ # For GPU acceleration, use: ghcr.io/speaches-ai/speaches:latest-cuda
+ # and add GPU device mapping (see docs/5-CONFIGURATION/local-tts.md)
+
+ open_notebook:
+ image: lfnovo/open_notebook:v1-latest
+ ports:
+ - "8502:8502"
+ - "5055:5055"
+ environment:
+ # REQUIRED: Change this to your own secret string
+ - OPEN_NOTEBOOK_ENCRYPTION_KEY=change-me-to-a-secret-string
+
+ # Database connection
+ - SURREAL_URL=ws://surrealdb:8000/rpc
+ - SURREAL_USER=root
+ - SURREAL_PASSWORD=root
+ - SURREAL_NAMESPACE=open_notebook
+ - SURREAL_DATABASE=open_notebook
+ volumes:
+ - ./notebook_data:/app/data
+ depends_on:
+ - surrealdb
+ - speaches
+ restart: always
+ pull_policy: always
+
+volumes:
+ hf-hub-cache:
+
+# ==========================================
+# AFTER STARTING: Download Speech Models
+# ==========================================
+#
+# For TTS (Text-to-Speech):
+# docker compose exec speaches uv tool run speaches-cli model download speaches-ai/Kokoro-82M-v1.0-ONNX
+#
+# For STT (Speech-to-Text):
+# docker compose exec speaches uv tool run speaches-cli model download Systran/faster-whisper-small
+#
+# ==========================================
+# CONFIGURATION IN OPEN NOTEBOOK
+# ==========================================
+#
+# 1. Go to Settings → API Keys
+# 2. Click "Add Credential" → Select "OpenAI-Compatible"
+# 3. Configure:
+# - Name: "Local Speaches"
+# - Base URL for TTS: http://host.docker.internal:8969/v1 (macOS/Windows)
+# or: http://172.17.0.1:8969/v1 (Linux)
+# - Base URL for STT: (same as TTS)
+# 4. Click Save → Test Connection
+#
+# 5. Go to Settings → Models
+# 6. Add TTS Model:
+# - Provider: openai_compatible
+# - Model Name: speaches-ai/Kokoro-82M-v1.0-ONNX
+# - Display Name: Local TTS
+#
+# 7. Add STT Model:
+# - Provider: openai_compatible
+# - Model Name: Systran/faster-whisper-small
+# - Display Name: Local Whisper
+#
+# ==========================================
+# TESTING
+# ==========================================
+#
+# Test TTS:
+# curl "http://localhost:8969/v1/audio/speech" -s \
+# -H "Content-Type: application/json" \
+# --output test.mp3 \
+# --data '{"input": "Hello, local TTS works!", "model": "speaches-ai/Kokoro-82M-v1.0-ONNX", "voice": "af_bella"}'
+#
+# Test STT:
+# curl "http://localhost:8969/v1/audio/transcriptions" \
+# -F "file=@test.mp3" \
+# -F "model=Systran/faster-whisper-small"
+#
+# Available voices: af_bella, af_sarah, am_adam, am_michael, bf_emma, bm_george
+# Available models: See docs/5-CONFIGURATION/local-stt.md for model sizes
diff --git a/frontend/src/app/(dashboard)/models/components/AddModelForm.tsx b/frontend/src/app/(dashboard)/models/components/AddModelForm.tsx
deleted file mode 100644
index 45066db..0000000
--- a/frontend/src/app/(dashboard)/models/components/AddModelForm.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-'use client'
-
-import { useId, useState } from 'react'
-import { useForm } from 'react-hook-form'
-import { CreateModelRequest, ProviderAvailability } from '@/lib/types/models'
-import { Button } from '@/components/ui/button'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
-import { useCreateModel } from '@/lib/hooks/use-models'
-import { Plus } from 'lucide-react'
-import { useTranslation } from '@/lib/hooks/use-translation'
-
-interface AddModelFormProps {
- modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
- providers: ProviderAvailability
-}
-
-export function AddModelForm({ modelType, providers }: AddModelFormProps) {
- const { t } = useTranslation()
- const providerSelectId = useId()
- const modelNameInputId = useId()
- const [open, setOpen] = useState(false)
- const createModel = useCreateModel()
- const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useForm({
- defaultValues: {
- type: modelType
- }
- })
-
- // Get available providers that support this model type
- const availableProviders = providers.available.filter(provider =>
- providers.supported_types[provider]?.includes(modelType)
- )
-
- const onSubmit = async (data: CreateModelRequest) => {
- await createModel.mutateAsync(data)
- reset()
- setOpen(false)
- }
-
- const getModelTypeName = () => {
- return (t.models as Record)[modelType] || modelType.replace(/_/g, ' ')
- }
-
- const getModelPlaceholder = () => {
- switch (modelType) {
- case 'language':
- return 'e.g., gpt-5-mini, claude, gemini'
- case 'embedding':
- return 'e.g., text-embedding-3-small'
- case 'text_to_speech':
- return 'e.g., tts-gpt-4o-mini-tts, tts-1-hd'
- case 'speech_to_text':
- return 'e.g., whisper-1'
- default:
- return t.models.enterModelName
- }
- }
-
- if (availableProviders.length === 0) {
- return (
-