* 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 <git@jfmd.us> 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 <git@jfmd.us> Co-authored-by: OraCatQAQ <570768706@qq.com>
This commit is contained in:
parent
b1d7a18ce8
commit
3f352cfcce
103 changed files with 10681 additions and 2669 deletions
305
.env.example
305
.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=
|
||||
# For more configuration options, see:
|
||||
# https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/environment-reference.md
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -131,6 +131,7 @@ doc_exports/
|
|||
|
||||
specs/
|
||||
.claude
|
||||
.sisyphus
|
||||
|
||||
.playwright-mcp/
|
||||
|
||||
|
|
|
|||
36
CHANGELOG.md
36
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
101
README.md
101
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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
112
api/CLAUDE.md
112
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": []}
|
||||
```
|
||||
|
|
|
|||
16
api/auth.py
16
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
|
||||
|
||||
|
|
|
|||
883
api/credentials_service.py
Normal file
883
api/credentials_service.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
17
api/main.py
17
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("/")
|
||||
|
|
|
|||
230
api/models.py
230
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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
386
api/routers/credentials.py
Normal file
386
api/routers/credentials.py
Normal file
|
|
@ -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=<credential_id> 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")
|
||||
|
|
@ -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)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <model>`
|
||||
- Ollama: Run `ollama pull <model>`, 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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
390
docs/3-USER-GUIDE/api-configuration.md
Normal file
390
docs/3-USER-GUIDE/api-configuration.md
Normal file
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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!**
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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'))"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
96
docs/SECURITY_REVIEW.md
Normal file
96
docs/SECURITY_REVIEW.md
Normal file
|
|
@ -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**
|
||||
163
examples/README.md
Normal file
163
examples/README.md
Normal file
|
|
@ -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)
|
||||
197
examples/docker-compose-full-local.yml
Normal file
197
examples/docker-compose-full-local.yml
Normal file
|
|
@ -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
|
||||
63
examples/docker-compose-ollama.yml
Normal file
63
examples/docker-compose-ollama.yml
Normal file
|
|
@ -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:
|
||||
125
examples/docker-compose-speaches.yml
Normal file
125
examples/docker-compose-speaches.yml
Normal file
|
|
@ -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
|
||||
|
|
@ -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<CreateModelRequest>({
|
||||
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<string, string>)[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 (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t.models.noProvidersForType.replace('{type}', getModelTypeName())}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
setOpen(isOpen)
|
||||
if (!isOpen) {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
id={`add-model-${modelType}`}
|
||||
name={`add-model-${modelType}`}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t.models.addModel}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t.models.addSpecificModel.replace('{type}', getModelTypeName())}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.models.addSpecificModelDesc.replace('{type}', getModelTypeName())}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor={providerSelectId}>{t.models.provider}</Label>
|
||||
<Select
|
||||
name="provider"
|
||||
onValueChange={(value) => setValue('provider', value)}
|
||||
required
|
||||
>
|
||||
<SelectTrigger id={providerSelectId}>
|
||||
<SelectValue placeholder={t.models.selectProviderPlaceholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableProviders.map((provider) => (
|
||||
<SelectItem key={provider} value={provider}>
|
||||
<span className="capitalize">{provider}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.provider && (
|
||||
<p className="text-sm text-destructive mt-1">{t.models.providerRequired}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor={modelNameInputId}>{t.models.modelName}</Label>
|
||||
<Input
|
||||
id={modelNameInputId}
|
||||
{...register('name', { required: t.models.modelNameRequired })}
|
||||
placeholder={getModelPlaceholder()}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{modelType === 'language' && watch('provider') === 'azure' &&
|
||||
t.models.azureHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" disabled={createModel.isPending}>
|
||||
{createModel.isPending ? t.models.adding : t.models.addModel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,280 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState, useId } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ModelDefaults, Model } from '@/lib/types/models'
|
||||
import { useUpdateModelDefaults } from '@/lib/hooks/use-models'
|
||||
import { AlertCircle, X } from 'lucide-react'
|
||||
import { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
|
||||
interface DefaultModelsSectionProps {
|
||||
models: Model[]
|
||||
defaults: ModelDefaults
|
||||
}
|
||||
|
||||
export function DefaultModelsSection({ models, defaults }: DefaultModelsSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const updateDefaults = useUpdateModelDefaults()
|
||||
const { setValue, watch } = useForm<ModelDefaults>({
|
||||
defaultValues: defaults
|
||||
})
|
||||
|
||||
interface DefaultConfig {
|
||||
key: keyof ModelDefaults
|
||||
label: string
|
||||
description: string
|
||||
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
||||
required?: boolean
|
||||
id: string
|
||||
}
|
||||
|
||||
const generatedId = useId()
|
||||
|
||||
const defaultConfigs: DefaultConfig[] = [
|
||||
{
|
||||
key: 'default_chat_model',
|
||||
label: t.models.chatModelLabel,
|
||||
description: t.models.chatModelDesc,
|
||||
modelType: 'language',
|
||||
required: true,
|
||||
id: `${generatedId}-chat`,
|
||||
},
|
||||
{
|
||||
key: 'default_transformation_model',
|
||||
label: t.models.transformationModelLabel,
|
||||
description: t.models.transformationModelDesc,
|
||||
modelType: 'language',
|
||||
required: true,
|
||||
id: `${generatedId}-transformation`,
|
||||
},
|
||||
{
|
||||
key: 'default_tools_model',
|
||||
label: t.models.toolsModelLabel,
|
||||
description: t.models.toolsModelDesc,
|
||||
modelType: 'language',
|
||||
id: `${generatedId}-tools`,
|
||||
},
|
||||
{
|
||||
key: 'large_context_model',
|
||||
label: t.models.largeContextModelLabel,
|
||||
description: t.models.largeContextModelDesc,
|
||||
modelType: 'language',
|
||||
id: `${generatedId}-large-context`,
|
||||
},
|
||||
{
|
||||
key: 'default_embedding_model',
|
||||
label: t.models.embeddingModelLabel,
|
||||
description: t.models.embeddingModelDesc,
|
||||
modelType: 'embedding',
|
||||
required: true,
|
||||
id: `${generatedId}-embedding`,
|
||||
},
|
||||
{
|
||||
key: 'default_text_to_speech_model',
|
||||
label: t.models.ttsModelLabel,
|
||||
description: t.models.ttsModelDesc,
|
||||
modelType: 'text_to_speech',
|
||||
id: `${generatedId}-tts`,
|
||||
},
|
||||
{
|
||||
key: 'default_speech_to_text_model',
|
||||
label: t.models.sttModelLabel,
|
||||
description: t.models.sttModelDesc,
|
||||
modelType: 'speech_to_text',
|
||||
id: `${generatedId}-stt`,
|
||||
},
|
||||
]
|
||||
|
||||
// State for embedding model change dialog
|
||||
const [showEmbeddingDialog, setShowEmbeddingDialog] = useState(false)
|
||||
const [pendingEmbeddingChange, setPendingEmbeddingChange] = useState<{
|
||||
key: keyof ModelDefaults
|
||||
value: string
|
||||
oldModelId?: string
|
||||
newModelId?: string
|
||||
} | null>(null)
|
||||
|
||||
// Update form when defaults change
|
||||
useEffect(() => {
|
||||
if (defaults) {
|
||||
Object.entries(defaults).forEach(([key, value]) => {
|
||||
setValue(key as keyof ModelDefaults, value)
|
||||
})
|
||||
}
|
||||
}, [defaults, setValue])
|
||||
|
||||
const handleChange = (key: keyof ModelDefaults, value: string) => {
|
||||
// Special handling for embedding model changes
|
||||
if (key === 'default_embedding_model') {
|
||||
const currentEmbeddingModel = defaults[key]
|
||||
|
||||
// Only show dialog if there's an existing embedding model and it's changing
|
||||
if (currentEmbeddingModel && currentEmbeddingModel !== value) {
|
||||
setPendingEmbeddingChange({
|
||||
key,
|
||||
value,
|
||||
oldModelId: currentEmbeddingModel,
|
||||
newModelId: value
|
||||
})
|
||||
setShowEmbeddingDialog(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// For all other changes or new embedding model assignment
|
||||
const newDefaults = { [key]: value || null }
|
||||
updateDefaults.mutate(newDefaults)
|
||||
}
|
||||
|
||||
const handleConfirmEmbeddingChange = () => {
|
||||
if (pendingEmbeddingChange) {
|
||||
const newDefaults = {
|
||||
[pendingEmbeddingChange.key]: pendingEmbeddingChange.value || null
|
||||
}
|
||||
updateDefaults.mutate(newDefaults)
|
||||
setPendingEmbeddingChange(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelEmbeddingChange = () => {
|
||||
setPendingEmbeddingChange(null)
|
||||
setShowEmbeddingDialog(false)
|
||||
}
|
||||
|
||||
const getModelsForType = (type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text') => {
|
||||
return models.filter(model => model.type === type)
|
||||
}
|
||||
|
||||
const missingRequired = defaultConfigs
|
||||
.filter(config => {
|
||||
if (!config.required) return false
|
||||
const value = defaults[config.key]
|
||||
if (!value) return true
|
||||
// Check if the model still exists
|
||||
const modelsOfType = models.filter(m => m.type === config.modelType)
|
||||
return !modelsOfType.some(m => m.id === value)
|
||||
})
|
||||
.map(config => config.label)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.models.defaultAssignments}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.models.defaultAssignmentsDesc}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{missingRequired.length > 0 && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{t.models.missingRequiredModels.replace('{models}', missingRequired.join(', '))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{defaultConfigs.map((config) => {
|
||||
const availableModels = getModelsForType(config.modelType)
|
||||
const currentValue = watch(config.key) || undefined
|
||||
|
||||
// Check if the current value exists in available models
|
||||
const isValidModel = currentValue && availableModels.some(m => m.id === currentValue)
|
||||
|
||||
return (
|
||||
<div key={config.key} className="space-y-2">
|
||||
<Label htmlFor={config.id}>
|
||||
{config.label}
|
||||
{config.required && <span className="text-destructive ml-1">*</span>}
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={currentValue || ""}
|
||||
onValueChange={(value) => handleChange(config.key, value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={config.id}
|
||||
className={
|
||||
config.required && !isValidModel && availableModels.length > 0
|
||||
? 'border-destructive'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<SelectValue placeholder={
|
||||
config.required && !isValidModel && availableModels.length > 0
|
||||
? t.models.requiredModelPlaceholder
|
||||
: t.models.selectModelPlaceholder
|
||||
} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.sort((a, b) => a.name.localeCompare(b.name)).map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span>{model.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
{model.provider}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!config.required && currentValue && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleChange(config.key, "")}
|
||||
className="h-10 w-10"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
<a
|
||||
href="https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t.models.whichModelToChoose}
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Embedding Model Change Dialog */}
|
||||
<EmbeddingModelChangeDialog
|
||||
open={showEmbeddingDialog}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleCancelEmbeddingChange()
|
||||
}
|
||||
}}
|
||||
onConfirm={handleConfirmEmbeddingChange}
|
||||
oldModelName={
|
||||
pendingEmbeddingChange?.oldModelId
|
||||
? models.find(m => m.id === pendingEmbeddingChange.oldModelId)?.name
|
||||
: undefined
|
||||
}
|
||||
newModelName={
|
||||
pendingEmbeddingChange?.newModelId
|
||||
? models.find(m => m.id === pendingEmbeddingChange.newModelId)?.name
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { Model, ProviderAvailability } from '@/lib/types/models'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AddModelForm } from './AddModelForm'
|
||||
import { Bot, Mic, Volume2, Search, Trash2, X, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { useDeleteModel } from '@/lib/hooks/use-models'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
|
||||
interface ModelTypeSectionProps {
|
||||
type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
||||
models: Model[]
|
||||
providers: ProviderAvailability
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
const COLLAPSED_ITEM_COUNT = 5
|
||||
|
||||
export function ModelTypeSection({ type, models, providers, isLoading }: ModelTypeSectionProps) {
|
||||
const { t } = useTranslation()
|
||||
const [deleteModel, setDeleteModel] = useState<Model | null>(null)
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null)
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const deleteModelMutation = useDeleteModel()
|
||||
|
||||
const getTypeInfo = () => {
|
||||
switch (type) {
|
||||
case 'language':
|
||||
return {
|
||||
title: t.models.language,
|
||||
description: t.models.languageDesc,
|
||||
icon: Bot,
|
||||
iconColor: 'text-blue-500',
|
||||
bgColor: 'bg-blue-50 dark:bg-blue-950/20'
|
||||
}
|
||||
case 'embedding':
|
||||
return {
|
||||
title: t.models.embedding,
|
||||
description: t.models.embeddingDesc,
|
||||
icon: Search,
|
||||
iconColor: 'text-green-500',
|
||||
bgColor: 'bg-green-50 dark:bg-green-950/20'
|
||||
}
|
||||
case 'text_to_speech':
|
||||
return {
|
||||
title: t.models.tts,
|
||||
description: t.models.ttsDesc,
|
||||
icon: Volume2,
|
||||
iconColor: 'text-purple-500',
|
||||
bgColor: 'bg-purple-50 dark:bg-purple-950/20'
|
||||
}
|
||||
case 'speech_to_text':
|
||||
return {
|
||||
title: t.models.stt,
|
||||
description: t.models.sttDesc,
|
||||
icon: Mic,
|
||||
iconColor: 'text-orange-500',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-950/20'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { title, description, icon: Icon, iconColor, bgColor } = getTypeInfo()
|
||||
|
||||
// Filter and sort models
|
||||
const filteredModels = useMemo(() => {
|
||||
let filtered = models.filter(model => model.type === type)
|
||||
|
||||
// Apply provider filter if selected
|
||||
if (selectedProvider) {
|
||||
filtered = filtered.filter(model => model.provider === selectedProvider)
|
||||
}
|
||||
|
||||
// Sort by name alphabetically
|
||||
return filtered.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}, [models, type, selectedProvider])
|
||||
|
||||
// Get unique providers for this model type
|
||||
const modelProviders = useMemo(() => {
|
||||
const typeModels = models.filter(model => model.type === type)
|
||||
const uniqueProviders = [...new Set(typeModels.map(m => m.provider))]
|
||||
return uniqueProviders.sort()
|
||||
}, [models, type])
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteModel) {
|
||||
deleteModelMutation.mutate(deleteModel.id)
|
||||
setDeleteModel(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="h-full">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`rounded-lg p-2 ${bgColor}`}>
|
||||
<Icon className={`h-5 w-5 ${iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
<CardDescription className="text-xs">{description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<AddModelForm modelType={type} providers={providers} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{/* Provider filter badges */}
|
||||
{modelProviders.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
<Badge
|
||||
variant={selectedProvider === null ? "default" : "outline"}
|
||||
className="cursor-pointer text-xs"
|
||||
onClick={() => setSelectedProvider(null)}
|
||||
>
|
||||
{t.models.all}
|
||||
</Badge>
|
||||
{modelProviders.map(provider => (
|
||||
<Badge
|
||||
key={provider}
|
||||
variant={selectedProvider === provider ? "default" : "outline"}
|
||||
className="cursor-pointer text-xs capitalize"
|
||||
onClick={() => setSelectedProvider(provider === selectedProvider ? null : provider)}
|
||||
>
|
||||
{provider}
|
||||
{selectedProvider === provider && (
|
||||
<X className="ml-1 h-2.5 w-2.5" />
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
) : filteredModels.length === 0 ? (
|
||||
<div className="text-center py-6 text-sm text-muted-foreground">
|
||||
{selectedProvider
|
||||
? t.models.noProviderModelsConfigured.replace('{provider}', selectedProvider)
|
||||
: t.models.noModelsConfigured
|
||||
}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className={`space-y-2 ${!isExpanded && filteredModels.length > COLLAPSED_ITEM_COUNT ? 'max-h-[280px] overflow-hidden relative' : ''}`}>
|
||||
{filteredModels.slice(0, isExpanded ? undefined : COLLAPSED_ITEM_COUNT).map(model => (
|
||||
<div key={model.id} className="flex items-center gap-2 group">
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-2 rounded-md border bg-muted/30">
|
||||
<span className="font-medium text-sm">{model.name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{model.provider}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => setDeleteModel(model)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{!isExpanded && filteredModels.length > COLLAPSED_ITEM_COUNT && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-20 bg-gradient-to-t from-background to-transparent pointer-events-none" />
|
||||
)}
|
||||
</div>
|
||||
{filteredModels.length > COLLAPSED_ITEM_COUNT && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="h-4 w-4 mr-2" />
|
||||
{t.models.seeLess}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
{t.models.showMore.replace('{count}', (filteredModels.length - COLLAPSED_ITEM_COUNT).toString())}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteModel}
|
||||
onOpenChange={(open) => !open && setDeleteModel(null)}
|
||||
title={t.models.deleteModel}
|
||||
description={t.models.deleteModelDesc.replace('{name}', deleteModel?.name || '')}
|
||||
confirmText={t.common.delete}
|
||||
confirmVariant="destructive"
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { ProviderAvailability } from '@/lib/types/models'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
|
||||
interface ProviderStatusProps {
|
||||
providers: ProviderAvailability
|
||||
}
|
||||
|
||||
export function ProviderStatus({ providers }: ProviderStatusProps) {
|
||||
const { t } = useTranslation()
|
||||
// Combine all providers, with available ones first
|
||||
const allProviders = useMemo(
|
||||
() => [
|
||||
...providers.available.map((p) => ({ name: p, available: true })),
|
||||
...providers.unavailable.map((p) => ({ name: p, available: false })),
|
||||
],
|
||||
[providers.available, providers.unavailable],
|
||||
)
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
|
||||
const visibleProviders = useMemo(() => {
|
||||
if (expanded) {
|
||||
return allProviders
|
||||
}
|
||||
return allProviders.slice(0, 6)
|
||||
}, [allProviders, expanded])
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t.models.aiProviders}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.models.providerConfigDesc}
|
||||
<span className="ml-1">
|
||||
{t.models.configuredCount
|
||||
.replace('{count}', providers.available.length.toString())
|
||||
.replace('{total}', allProviders.length.toString())}
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{visibleProviders.map((provider) => {
|
||||
const supportedTypes = providers.supported_types[provider.name] ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.name}
|
||||
className={`flex items-center gap-3 rounded-lg border px-4 py-3 transition-colors ${
|
||||
provider.available ? 'bg-card' : 'bg-muted/40'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center justify-center rounded-full p-1.5 ${
|
||||
provider.available
|
||||
? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/20 dark:text-emerald-300'
|
||||
: 'bg-muted-foreground/10 text-muted-foreground'
|
||||
}`}>
|
||||
{provider.available ? (
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||
<span
|
||||
className={`truncate text-sm font-medium capitalize ${
|
||||
!provider.available ? 'text-muted-foreground' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{provider.name}
|
||||
</span>
|
||||
|
||||
{provider.available ? (
|
||||
<div className="flex flex-wrap items-center justify-end gap-1">
|
||||
{supportedTypes.length > 0 ? (
|
||||
supportedTypes.map((type) => (
|
||||
<Badge key={type} variant="secondary" className="text-xs font-medium">
|
||||
{(t.models as Record<string, string>)[type] || type.replace('_', ' ')}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs">{t.models.noModels}</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground border-dashed">
|
||||
{t.models.notConfigured}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{allProviders.length > 6 ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
{expanded
|
||||
? t.models.seeLess
|
||||
: t.models.seeAll.replace('{count}', allProviders.length.toString())}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<a
|
||||
href="https://github.com/lfnovo/open-notebook/blob/main/docs/5-CONFIGURATION/ai-providers.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t.models.learnMore}
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
'use client'
|
||||
|
||||
import { AppShell } from '@/components/layout/AppShell'
|
||||
import { ProviderStatus } from './components/ProviderStatus'
|
||||
import { ModelTypeSection } from './components/ModelTypeSection'
|
||||
import { DefaultModelsSection } from './components/DefaultModelsSection'
|
||||
import { useModels, useModelDefaults, useProviders } from '@/lib/hooks/use-models'
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
|
||||
export default function ModelsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { data: models, isLoading: modelsLoading, refetch: refetchModels } = useModels()
|
||||
const { data: defaults, isLoading: defaultsLoading, refetch: refetchDefaults } = useModelDefaults()
|
||||
const { data: providers, isLoading: providersLoading, refetch: refetchProviders } = useProviders()
|
||||
|
||||
const handleRefresh = () => {
|
||||
refetchModels()
|
||||
refetchDefaults()
|
||||
refetchProviders()
|
||||
}
|
||||
|
||||
if (modelsLoading || defaultsLoading || providersLoading) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (!models || !defaults || !providers) {
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="p-6">
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">{t.models.failedToLoad}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{t.models.title}</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{t.models.desc}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6">
|
||||
{/* Provider Status */}
|
||||
<ProviderStatus providers={providers} />
|
||||
|
||||
{/* Default Models */}
|
||||
<DefaultModelsSection models={models} defaults={defaults} />
|
||||
|
||||
{/* Model Types */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<ModelTypeSection
|
||||
type="language"
|
||||
models={models}
|
||||
providers={providers}
|
||||
isLoading={modelsLoading}
|
||||
/>
|
||||
<ModelTypeSection
|
||||
type="embedding"
|
||||
models={models}
|
||||
providers={providers}
|
||||
isLoading={modelsLoading}
|
||||
/>
|
||||
<ModelTypeSection
|
||||
type="text_to_speech"
|
||||
models={models}
|
||||
providers={providers}
|
||||
isLoading={modelsLoading}
|
||||
/>
|
||||
<ModelTypeSection
|
||||
type="speech_to_text"
|
||||
models={models}
|
||||
providers={providers}
|
||||
isLoading={modelsLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
1395
frontend/src/app/(dashboard)/settings/api-keys/page.tsx
Normal file
1395
frontend/src/app/(dashboard)/settings/api-keys/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -22,6 +22,7 @@ export default function SettingsPage() {
|
|||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SettingsForm />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ const getNavigationItems = (t: TranslationKeys) => [
|
|||
{ name: t.navigation.notebooks, href: '/notebooks', icon: Book, keywords: ['notes', 'research', 'projects'] },
|
||||
{ name: t.navigation.askAndSearch, href: '/search', icon: Search, keywords: ['find', 'query'] },
|
||||
{ name: t.navigation.podcasts, href: '/podcasts', icon: Mic, keywords: ['audio', 'episodes', 'generate'] },
|
||||
{ name: t.navigation.models, href: '/models', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },
|
||||
{ name: t.navigation.models, href: '/settings/api-keys', icon: Bot, keywords: ['ai', 'llm', 'providers', 'openai', 'anthropic'] },
|
||||
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle, keywords: ['prompts', 'templates', 'actions'] },
|
||||
{ name: t.navigation.settings, href: '/settings', icon: Settings, keywords: ['preferences', 'config', 'options'] },
|
||||
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench, keywords: ['debug', 'system', 'tools'] },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { AppSidebar } from './AppSidebar'
|
||||
import { SetupBanner } from './SetupBanner'
|
||||
|
||||
interface AppShellProps {
|
||||
children: React.ReactNode
|
||||
|
|
@ -11,6 +12,7 @@ export function AppShell({ children }: AppShellProps) {
|
|||
<div className="flex h-screen overflow-hidden">
|
||||
<AppSidebar />
|
||||
<main className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<SetupBanner />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ const getNavigation = (t: TranslationKeys) => [
|
|||
{
|
||||
title: t.navigation.manage,
|
||||
items: [
|
||||
{ name: t.navigation.models, href: '/models', icon: Bot },
|
||||
{ name: t.navigation.models, href: '/settings/api-keys', icon: Bot },
|
||||
{ name: t.navigation.transformations, href: '/transformations', icon: Shuffle },
|
||||
{ name: t.navigation.settings, href: '/settings', icon: Settings },
|
||||
{ name: t.navigation.advanced, href: '/advanced', icon: Wrench },
|
||||
|
|
|
|||
84
frontend/src/components/layout/SetupBanner.tsx
Normal file
84
frontend/src/components/layout/SetupBanner.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ShieldAlert, AlertTriangle, ArrowRight, ExternalLink } from 'lucide-react'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { useCredentialStatus, useEnvStatus } from '@/lib/hooks/use-credentials'
|
||||
|
||||
export function SetupBanner() {
|
||||
const { t } = useTranslation()
|
||||
const { data: credentialStatus } = useCredentialStatus()
|
||||
const { data: envStatus } = useEnvStatus()
|
||||
|
||||
const encryptionReady = credentialStatus?.encryption_configured ?? true
|
||||
|
||||
const providersToMigrate = useMemo(() => {
|
||||
if (!envStatus || !credentialStatus) return []
|
||||
const providers: string[] = []
|
||||
for (const provider in envStatus) {
|
||||
if (envStatus[provider] && credentialStatus.source[provider] === 'environment') {
|
||||
providers.push(provider)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}, [envStatus, credentialStatus])
|
||||
|
||||
if (encryptionReady && providersToMigrate.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!encryptionReady) {
|
||||
return (
|
||||
<div className="px-4 pt-3">
|
||||
<Alert className="border-red-500/50 bg-red-50 dark:bg-red-950/20">
|
||||
<ShieldAlert className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
<AlertTitle className="text-red-800 dark:text-red-200">
|
||||
{t.setupBanner.encryptionRequired}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between text-red-700 dark:text-red-300">
|
||||
<span>{t.setupBanner.encryptionRequiredDescription}</span>
|
||||
<a
|
||||
href="https://github.com/lfnovo/open-notebook/blob/main/docs/3-USER-GUIDE/api-configuration.md#encryption-setup"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center shrink-0 text-sm font-medium underline underline-offset-2 hover:text-red-900 dark:hover:text-red-100"
|
||||
>
|
||||
{t.setupBanner.viewDocs}
|
||||
<ExternalLink className="ml-1 h-3 w-3" />
|
||||
</a>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 pt-3">
|
||||
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<AlertTitle className="text-amber-800 dark:text-amber-200">
|
||||
{t.setupBanner.migrationAvailable}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-amber-700 dark:text-amber-300">
|
||||
{t.setupBanner.migrationDescription.replace('{count}', providersToMigrate.length.toString())}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
className="shrink-0 border-amber-500 text-amber-700 hover:bg-amber-100 dark:border-amber-400 dark:text-amber-300 dark:hover:bg-amber-900/30"
|
||||
>
|
||||
<Link href="/settings/api-keys">
|
||||
{t.setupBanner.goToSettings}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -9,7 +9,8 @@ import { useEpisodeProfiles, useGeneratePodcast } from '@/lib/hooks/use-podcasts
|
|||
import { chatApi } from '@/lib/api/chat'
|
||||
import { sourcesApi } from '@/lib/api/sources'
|
||||
import { notesApi } from '@/lib/api/notes'
|
||||
import { BuildContextRequest, NoteResponse, SourceListResponse } from '@/lib/types/api'
|
||||
import { BuildContextRequest, NoteResponse, NotebookResponse, SourceListResponse } from '@/lib/types/api'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { PodcastGenerationRequest } from '@/lib/types/podcasts'
|
||||
import { QUERY_KEYS } from '@/lib/api/query-client'
|
||||
import { useToast } from '@/lib/hooks/use-toast'
|
||||
|
|
@ -69,6 +70,30 @@ interface GeneratePodcastDialogProps {
|
|||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
interface NotebookSummary {
|
||||
notebookId: string
|
||||
sources: number
|
||||
notes: number
|
||||
}
|
||||
|
||||
interface ContentSelectionPanelProps {
|
||||
notebooks: NotebookResponse[]
|
||||
isLoading: boolean
|
||||
selectedNotebookSummaries: NotebookSummary[]
|
||||
tokenCount: number
|
||||
charCount: number
|
||||
expandedNotebooks: string[]
|
||||
setExpandedNotebooks: (notebooks: string[]) => void
|
||||
selections: Record<string, NotebookSelection>
|
||||
sourcesByNotebook: Record<string, SourceListResponse[]>
|
||||
notesByNotebook: Record<string, NoteResponse[]>
|
||||
fetchingNotebookIds: Set<string>
|
||||
handleNotebookToggle: (notebookId: string, checked: boolean | 'indeterminate') => void
|
||||
handleSourceModeChange: (notebookId: string, sourceId: string, mode: SourceMode) => void
|
||||
handleNoteToggle: (notebookId: string, noteId: string, checked: boolean | 'indeterminate') => void
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
||||
// Extracted component for content selection panel
|
||||
function ContentSelectionPanel({
|
||||
notebooks,
|
||||
|
|
@ -86,7 +111,7 @@ function ContentSelectionPanel({
|
|||
handleSourceModeChange,
|
||||
handleNoteToggle,
|
||||
queryClient,
|
||||
}: any) {
|
||||
}: ContentSelectionPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
|
||||
// Cache all translation strings at render time to avoid repeated Proxy accesses in loops
|
||||
|
|
@ -138,7 +163,7 @@ function ContentSelectionPanel({
|
|||
{tr.itemsSelected.replace(
|
||||
'{count}',
|
||||
selectedNotebookSummaries.reduce(
|
||||
(acc: number, summary: any) => acc + summary.sources + summary.notes,
|
||||
(acc: number, summary: NotebookSummary) => acc + summary.sources + summary.notes,
|
||||
0
|
||||
).toString()
|
||||
)}
|
||||
|
|
@ -170,7 +195,7 @@ function ContentSelectionPanel({
|
|||
onValueChange={(value) => setExpandedNotebooks(value as string[])}
|
||||
className="w-full"
|
||||
>
|
||||
{notebooks.map((notebook: any, index: number) => {
|
||||
{notebooks.map((notebook: NotebookResponse, index: number) => {
|
||||
const sources = sourcesByNotebook[notebook.id] ?? []
|
||||
const notes = notesByNotebook[notebook.id] ?? []
|
||||
const selection = selections[notebook.id]
|
||||
|
|
@ -239,7 +264,7 @@ function ContentSelectionPanel({
|
|||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sources.map((source: any) => {
|
||||
{sources.map((source: SourceListResponse) => {
|
||||
const mode = selection?.sources?.[source.id] ?? 'off'
|
||||
return (
|
||||
<div
|
||||
|
|
@ -318,7 +343,7 @@ function ContentSelectionPanel({
|
|||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{notes.map((note: any) => {
|
||||
{notes.map((note: NoteResponse) => {
|
||||
const mode = selection?.notes?.[note.id] ?? 'off'
|
||||
return (
|
||||
<div
|
||||
|
|
@ -370,7 +395,7 @@ function ContentSelectionPanel({
|
|||
}
|
||||
|
||||
export function GeneratePodcastDialog({ open, onOpenChange }: GeneratePodcastDialogProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const { toast } = useToast()
|
||||
const queryClient = useQueryClient()
|
||||
const [expandedNotebooks, setExpandedNotebooks] = useState<string[]>([])
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ export function EmbeddingModelChangeDialog({
|
|||
</p>
|
||||
|
||||
<div className="bg-muted p-4 rounded-md space-y-2">
|
||||
<p className="font-semibold text-foreground">⚠️ {t.models.rebuildRequired}</p>
|
||||
<p className="font-semibold text-foreground">{t.models.rebuildRequired}</p>
|
||||
<p className="text-sm">
|
||||
{t.models.rebuildReason}
|
||||
</p>
|
||||
53
frontend/src/components/settings/MigrationBanner.tsx
Normal file
53
frontend/src/components/settings/MigrationBanner.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
'use client'
|
||||
|
||||
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, ArrowRight, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { useMigrateFromEnv } from '@/lib/hooks/use-credentials'
|
||||
|
||||
interface MigrationBannerProps {
|
||||
providersToMigrate: string[]
|
||||
}
|
||||
|
||||
export function MigrationBanner({ providersToMigrate }: MigrationBannerProps) {
|
||||
const { t } = useTranslation()
|
||||
const migrate = useMigrateFromEnv()
|
||||
|
||||
if (providersToMigrate.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="border-amber-500/50 bg-amber-50 dark:bg-amber-950/20">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
<AlertTitle className="text-amber-800 dark:text-amber-200">
|
||||
{t.apiKeys.migrationAvailable}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span className="text-amber-700 dark:text-amber-300">
|
||||
{t.apiKeys.migrationDescription.replace('{count}', providersToMigrate.length.toString())}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => migrate.mutate()}
|
||||
disabled={migrate.isPending}
|
||||
className="shrink-0 border-amber-500 text-amber-700 hover:bg-amber-100 dark:border-amber-400 dark:text-amber-300 dark:hover:bg-amber-900/30"
|
||||
>
|
||||
{migrate.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t.apiKeys.migrating}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{t.apiKeys.migrateToDatabase}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
63
frontend/src/components/settings/ModelTestResultDialog.tsx
Normal file
63
frontend/src/components/settings/ModelTestResultDialog.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Check, X } from 'lucide-react'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { ModelTestResult } from '@/lib/types/models'
|
||||
|
||||
export function ModelTestResultDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
result,
|
||||
modelName,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
result: ModelTestResult | null
|
||||
modelName: string
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!result) return null
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{result.success ? (
|
||||
<Check className="h-5 w-5 text-emerald-500" />
|
||||
) : (
|
||||
<X className="h-5 w-5 text-destructive" />
|
||||
)}
|
||||
{result.success ? t.models.testModelSuccess : t.models.testModelFailed}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{modelName}</p>
|
||||
<p className="text-sm">{result.message}</p>
|
||||
|
||||
{result.details && (
|
||||
<pre className="text-xs bg-muted p-3 rounded-md overflow-auto max-h-60 whitespace-pre-wrap break-words">
|
||||
{result.details}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t.common.done}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/settings/index.ts
Normal file
3
frontend/src/components/settings/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { MigrationBanner } from './MigrationBanner'
|
||||
export { EmbeddingModelChangeDialog } from './EmbeddingModelChangeDialog'
|
||||
export { ModelTestResultDialog } from './ModelTestResultDialog'
|
||||
|
|
@ -64,3 +64,95 @@ const response = await sourcesApi.create({
|
|||
// With auth token (auto-added by interceptor)
|
||||
const notes = await notesApi.list()
|
||||
```
|
||||
|
||||
## Credentials Module (`credentials.ts`)
|
||||
|
||||
Client functions for managing AI provider credentials (API keys, base URLs, endpoints) stored encrypted in SurrealDB.
|
||||
|
||||
### Type Definitions
|
||||
|
||||
```typescript
|
||||
// Full credential object (api_key never exposed)
|
||||
interface Credential {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
modalities: string[]
|
||||
has_api_key: boolean
|
||||
model_count: number
|
||||
base_url?: string
|
||||
endpoint?: string
|
||||
api_version?: string
|
||||
// ... endpoint_llm, endpoint_embedding, endpoint_stt, endpoint_tts, project, location, credentials_path
|
||||
}
|
||||
|
||||
// Request payload for creating/updating credential
|
||||
interface CreateCredentialRequest {
|
||||
name: string
|
||||
provider: string
|
||||
modalities: string[]
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
// ... other provider-specific fields
|
||||
}
|
||||
|
||||
// Model discovery and registration
|
||||
interface DiscoverModelsResponse { provider: string; models: DiscoveredModel[]; credential_id: string }
|
||||
interface RegisterModelsRequest { models: RegisterModelData[] }
|
||||
|
||||
// Status and migration
|
||||
interface CredentialStatus { configured: Record<string, boolean>; source: Record<string, string>; encryption_configured: boolean }
|
||||
interface EnvStatus { [provider: string]: boolean }
|
||||
interface MigrationResult { message: string; migrated: string[]; skipped: string[]; errors: string[] }
|
||||
interface TestConnectionResult { provider: string; success: boolean; message: string }
|
||||
```
|
||||
|
||||
### API Functions
|
||||
|
||||
| Function | Description | Endpoint |
|
||||
|----------|-------------|----------|
|
||||
| `getStatus()` | Get configuration status of all providers | `GET /credentials/status` |
|
||||
| `getEnvStatus()` | Get which providers have env vars set | `GET /credentials/env-status` |
|
||||
| `list(provider?)` | List all credentials (optional filter) | `GET /credentials` |
|
||||
| `listByProvider(provider)` | List credentials for a provider | `GET /credentials/by-provider/{provider}` |
|
||||
| `get(credentialId)` | Get a specific credential | `GET /credentials/{credentialId}` |
|
||||
| `create(data)` | Create a new credential | `POST /credentials` |
|
||||
| `update(credentialId, data)` | Update a credential | `PUT /credentials/{credentialId}` |
|
||||
| `delete(credentialId, options?)` | Delete a credential | `DELETE /credentials/{credentialId}` |
|
||||
| `test(credentialId)` | Test connection using credential | `POST /credentials/{credentialId}/test` |
|
||||
| `discover(credentialId)` | Discover available models | `POST /credentials/{credentialId}/discover` |
|
||||
| `registerModels(credentialId, data)` | Register discovered models | `POST /credentials/{credentialId}/register-models` |
|
||||
| `migrateFromProviderConfig()` | Migrate from legacy ProviderConfig | `POST /credentials/migrate-from-provider-config` |
|
||||
| `migrateFromEnv()` | Migrate from env vars | `POST /credentials/migrate-from-env` |
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
import { credentialsApi } from '@/lib/api/credentials'
|
||||
|
||||
// Check which providers are configured
|
||||
const status = await credentialsApi.getStatus()
|
||||
if (status.configured['openai']) {
|
||||
console.log(`OpenAI configured via ${status.source['openai']}`)
|
||||
}
|
||||
|
||||
// Create a new credential
|
||||
const cred = await credentialsApi.create({
|
||||
name: 'My OpenAI Key',
|
||||
provider: 'openai',
|
||||
modalities: ['language', 'embedding'],
|
||||
api_key: 'sk-proj-...'
|
||||
})
|
||||
|
||||
// Test the connection
|
||||
const result = await credentialsApi.test(cred.id)
|
||||
if (result.success) {
|
||||
console.log('Connection successful!')
|
||||
}
|
||||
|
||||
// Discover and register models
|
||||
const discovered = await credentialsApi.discover(cred.id)
|
||||
await credentialsApi.registerModels(cred.id, {
|
||||
models: discovered.models.map(m => ({ model_id: m.model_id, name: m.name, type: 'language' }))
|
||||
})
|
||||
```
|
||||
|
|
|
|||
239
frontend/src/lib/api/credentials.ts
Normal file
239
frontend/src/lib/api/credentials.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import apiClient from './client'
|
||||
|
||||
// Types for credentials API
|
||||
export interface Credential {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
modalities: string[]
|
||||
base_url?: string | null
|
||||
endpoint?: string | null
|
||||
api_version?: string | null
|
||||
endpoint_llm?: string | null
|
||||
endpoint_embedding?: string | null
|
||||
endpoint_stt?: string | null
|
||||
endpoint_tts?: string | null
|
||||
project?: string | null
|
||||
location?: string | null
|
||||
credentials_path?: string | null
|
||||
has_api_key: boolean
|
||||
created: string
|
||||
updated: string
|
||||
model_count: number
|
||||
}
|
||||
|
||||
export interface CreateCredentialRequest {
|
||||
name: string
|
||||
provider: string
|
||||
modalities: string[]
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
endpoint?: string
|
||||
api_version?: string
|
||||
endpoint_llm?: string
|
||||
endpoint_embedding?: string
|
||||
endpoint_stt?: string
|
||||
endpoint_tts?: string
|
||||
project?: string
|
||||
location?: string
|
||||
credentials_path?: string
|
||||
}
|
||||
|
||||
export interface UpdateCredentialRequest {
|
||||
name?: string
|
||||
modalities?: string[]
|
||||
api_key?: string
|
||||
base_url?: string
|
||||
endpoint?: string
|
||||
api_version?: string
|
||||
endpoint_llm?: string
|
||||
endpoint_embedding?: string
|
||||
endpoint_stt?: string
|
||||
endpoint_tts?: string
|
||||
project?: string
|
||||
location?: string
|
||||
credentials_path?: string
|
||||
}
|
||||
|
||||
export interface DiscoveredModel {
|
||||
name: string
|
||||
provider: string
|
||||
model_type?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface RegisterModelData {
|
||||
name: string
|
||||
provider: string
|
||||
model_type: string
|
||||
}
|
||||
|
||||
export interface DiscoverModelsResponse {
|
||||
credential_id: string
|
||||
provider: string
|
||||
discovered: DiscoveredModel[]
|
||||
}
|
||||
|
||||
export interface RegisterModelsRequest {
|
||||
models: RegisterModelData[]
|
||||
}
|
||||
|
||||
export interface RegisterModelsResponse {
|
||||
created: number
|
||||
existing: number
|
||||
}
|
||||
|
||||
export interface TestConnectionResult {
|
||||
provider: string
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface CredentialDeleteResponse {
|
||||
message: string
|
||||
deleted_models: number
|
||||
}
|
||||
|
||||
export interface MigrationResult {
|
||||
message: string
|
||||
migrated: string[]
|
||||
skipped: string[]
|
||||
not_configured?: string[]
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface CredentialStatus {
|
||||
configured: Record<string, boolean>
|
||||
source: Record<string, string>
|
||||
encryption_configured: boolean
|
||||
}
|
||||
|
||||
export type EnvStatus = Record<string, boolean>
|
||||
|
||||
export const credentialsApi = {
|
||||
/**
|
||||
* Get configuration status for all providers
|
||||
*/
|
||||
getStatus: async (): Promise<CredentialStatus> => {
|
||||
const response = await apiClient.get<CredentialStatus>('/credentials/status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get environment variable status for all providers
|
||||
*/
|
||||
getEnvStatus: async (): Promise<EnvStatus> => {
|
||||
const response = await apiClient.get<EnvStatus>('/credentials/env-status')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* List all credentials, optionally filtered by provider
|
||||
*/
|
||||
list: async (provider?: string): Promise<Credential[]> => {
|
||||
const params = provider ? { provider } : {}
|
||||
const response = await apiClient.get<Credential[]>('/credentials', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* List credentials for a specific provider
|
||||
*/
|
||||
listByProvider: async (provider: string): Promise<Credential[]> => {
|
||||
const response = await apiClient.get<Credential[]>(`/credentials/by-provider/${provider}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a specific credential by ID
|
||||
*/
|
||||
get: async (credentialId: string): Promise<Credential> => {
|
||||
const response = await apiClient.get<Credential>(`/credentials/${credentialId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new credential
|
||||
*/
|
||||
create: async (data: CreateCredentialRequest): Promise<Credential> => {
|
||||
const response = await apiClient.post<Credential>('/credentials', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an existing credential
|
||||
*/
|
||||
update: async (credentialId: string, data: UpdateCredentialRequest): Promise<Credential> => {
|
||||
const response = await apiClient.put<Credential>(`/credentials/${credentialId}`, data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a credential
|
||||
*/
|
||||
delete: async (
|
||||
credentialId: string,
|
||||
options?: { delete_models?: boolean; migrate_to?: string }
|
||||
): Promise<CredentialDeleteResponse> => {
|
||||
const params: Record<string, string | boolean> = {}
|
||||
if (options?.delete_models) params.delete_models = true
|
||||
if (options?.migrate_to) params.migrate_to = options.migrate_to
|
||||
const response = await apiClient.delete<CredentialDeleteResponse>(
|
||||
`/credentials/${credentialId}`,
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Test connection for a credential
|
||||
*/
|
||||
test: async (credentialId: string): Promise<TestConnectionResult> => {
|
||||
const response = await apiClient.post<TestConnectionResult>(
|
||||
`/credentials/${credentialId}/test`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Discover models using a credential's API key
|
||||
*/
|
||||
discover: async (credentialId: string): Promise<DiscoverModelsResponse> => {
|
||||
const response = await apiClient.post<DiscoverModelsResponse>(
|
||||
`/credentials/${credentialId}/discover`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Register discovered models and link them to a credential
|
||||
*/
|
||||
registerModels: async (
|
||||
credentialId: string,
|
||||
data: RegisterModelsRequest
|
||||
): Promise<RegisterModelsResponse> => {
|
||||
const response = await apiClient.post<RegisterModelsResponse>(
|
||||
`/credentials/${credentialId}/register-models`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Migrate from ProviderConfig to individual credentials
|
||||
*/
|
||||
migrateFromProviderConfig: async (): Promise<MigrationResult> => {
|
||||
const response = await apiClient.post<MigrationResult>(
|
||||
'/credentials/migrate-from-provider-config'
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Migrate from environment variables to credentials
|
||||
*/
|
||||
migrateFromEnv: async (): Promise<MigrationResult> => {
|
||||
const response = await apiClient.post<MigrationResult>('/credentials/migrate-from-env')
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
|
@ -1,5 +1,16 @@
|
|||
import apiClient from './client'
|
||||
import { Model, CreateModelRequest, ModelDefaults, ProviderAvailability } from '@/lib/types/models'
|
||||
import {
|
||||
Model,
|
||||
CreateModelRequest,
|
||||
ModelDefaults,
|
||||
ProviderAvailability,
|
||||
DiscoveredModel,
|
||||
ProviderSyncResult,
|
||||
AllProvidersSyncResult,
|
||||
ProviderModelCount,
|
||||
AutoAssignResult,
|
||||
ModelTestResult,
|
||||
} from '@/lib/types/models'
|
||||
|
||||
export const modelsApi = {
|
||||
list: async () => {
|
||||
|
|
@ -34,5 +45,62 @@ export const modelsApi = {
|
|||
getProviders: async () => {
|
||||
const response = await apiClient.get<ProviderAvailability>('/models/providers')
|
||||
return response.data
|
||||
}
|
||||
},
|
||||
|
||||
// Model Discovery API
|
||||
/**
|
||||
* Discover available models from a provider without registering them
|
||||
*/
|
||||
discoverModels: async (provider: string) => {
|
||||
const response = await apiClient.get<DiscoveredModel[]>(`/models/discover/${provider}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Sync models for a specific provider (discover and register)
|
||||
*/
|
||||
syncProvider: async (provider: string) => {
|
||||
const response = await apiClient.post<ProviderSyncResult>(`/models/sync/${provider}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Sync models for all configured providers
|
||||
*/
|
||||
syncAll: async () => {
|
||||
const response = await apiClient.post<AllProvidersSyncResult>('/models/sync')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get count of registered models for a provider
|
||||
*/
|
||||
getProviderModelCount: async (provider: string) => {
|
||||
const response = await apiClient.get<ProviderModelCount>(`/models/count/${provider}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all models for a specific provider
|
||||
*/
|
||||
getByProvider: async (provider: string) => {
|
||||
const response = await apiClient.get<Model[]>(`/models/by-provider/${provider}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Auto-assign default models based on available models
|
||||
*/
|
||||
autoAssign: async () => {
|
||||
const response = await apiClient.post<AutoAssignResult>('/models/auto-assign')
|
||||
return response.data
|
||||
},
|
||||
|
||||
/**
|
||||
* Test an individual model configuration
|
||||
*/
|
||||
testModel: async (modelId: string): Promise<ModelTestResult> => {
|
||||
const response = await apiClient.post<ModelTestResult>(`/models/${modelId}/test`)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
|
@ -66,3 +66,128 @@ render(<Component />, { wrapper: QueryClientProvider })
|
|||
// Assert mutations trigger cache invalidation
|
||||
await waitFor(() => expect(queryClient.invalidateQueries).toHaveBeenCalled())
|
||||
```
|
||||
|
||||
## Credentials Hooks (`use-credentials.ts`)
|
||||
|
||||
Hooks for managing AI provider credentials with TanStack Query integration, toast notifications, and cache invalidation.
|
||||
|
||||
### Query Keys
|
||||
|
||||
```typescript
|
||||
export const CREDENTIAL_QUERY_KEYS = {
|
||||
all: ['credentials'] as const,
|
||||
status: ['credentials', 'status'] as const,
|
||||
envStatus: ['credentials', 'env-status'] as const,
|
||||
byProvider: (provider: string) => ['credentials', 'provider', provider] as const,
|
||||
detail: (id: string) => ['credentials', id] as const,
|
||||
}
|
||||
```
|
||||
|
||||
### Query Hooks
|
||||
|
||||
| Hook | Description | Returns |
|
||||
|------|-------------|---------|
|
||||
| `useCredentialStatus()` | Get configuration status of all providers | `{ configured, source, encryption_configured }` |
|
||||
| `useEnvStatus()` | Get which providers have env vars set | `{ [provider]: boolean }` |
|
||||
| `useCredentials(provider?)` | List all credentials (optional filter) | `Credential[]` |
|
||||
| `useCredentialsByProvider(provider)` | List credentials for a specific provider | `Credential[]` |
|
||||
| `useCredential(credentialId)` | Get a specific credential | `Credential` |
|
||||
|
||||
### Mutation Hooks
|
||||
|
||||
| Hook | Description | Cache Invalidation |
|
||||
|------|-------------|-------------------|
|
||||
| `useCreateCredential()` | Create new credential | `all`, `providers` |
|
||||
| `useUpdateCredential()` | Update credential | `all`, `providers` |
|
||||
| `useDeleteCredential()` | Delete credential | `all`, `models`, `providers` |
|
||||
| `useTestCredential()` | Test credential connection | None (stores result locally) |
|
||||
| `useDiscoverModels()` | Discover models for credential | None |
|
||||
| `useRegisterModels()` | Register discovered models | `models`, `all` |
|
||||
| `useMigrateFromEnv()` | Migrate from env vars | `status`, `envStatus`, `models`, `providers` |
|
||||
| `useMigrateFromProviderConfig()` | Migrate from legacy ProviderConfig | `status`, `envStatus`, `models`, `providers` |
|
||||
|
||||
### useTestCredential Details
|
||||
|
||||
Returns extended interface with local state management for test results:
|
||||
|
||||
```typescript
|
||||
const {
|
||||
testCredential, // (credentialId: string) => void
|
||||
testCredentialAsync, // (credentialId: string) => Promise<TestConnectionResult>
|
||||
isPending, // boolean
|
||||
testResults, // Record<string, TestConnectionResult>
|
||||
clearResult, // (credentialId: string) => void
|
||||
} = useTestCredential()
|
||||
```
|
||||
|
||||
### Cache Invalidation Strategy
|
||||
|
||||
All mutation hooks invalidate:
|
||||
- `CREDENTIAL_QUERY_KEYS.all` — refreshes all credential queries (cascades to filtered queries)
|
||||
- `MODEL_QUERY_KEYS.providers` — refreshes provider list
|
||||
|
||||
Delete hook additionally invalidates:
|
||||
- `MODEL_QUERY_KEYS.models` — refreshes full model list (linked models may be deleted)
|
||||
|
||||
Migration hooks additionally invalidate:
|
||||
- `CREDENTIAL_QUERY_KEYS.status` — refreshes configured/source info
|
||||
- `CREDENTIAL_QUERY_KEYS.envStatus` — refreshes env var status
|
||||
|
||||
### Usage Example
|
||||
|
||||
```typescript
|
||||
import {
|
||||
useCredentialStatus,
|
||||
useCredentials,
|
||||
useCreateCredential,
|
||||
useTestCredential,
|
||||
useMigrateFromEnv
|
||||
} from '@/lib/hooks/use-credentials'
|
||||
|
||||
function CredentialSettings() {
|
||||
const { data: status, isLoading } = useCredentialStatus()
|
||||
const { data: credentials } = useCredentials()
|
||||
const createCredential = useCreateCredential()
|
||||
const { testCredential, testResults, isPending } = useTestCredential()
|
||||
const migrateFromEnv = useMigrateFromEnv()
|
||||
|
||||
const handleCreate = () => {
|
||||
createCredential.mutate({
|
||||
name: 'My OpenAI Key',
|
||||
provider: 'openai',
|
||||
modalities: ['language', 'embedding'],
|
||||
api_key: 'sk-...'
|
||||
})
|
||||
}
|
||||
|
||||
const handleTest = (credentialId: string) => {
|
||||
testCredential(credentialId)
|
||||
}
|
||||
|
||||
const handleMigrate = () => {
|
||||
migrateFromEnv.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{credentials?.map(cred => (
|
||||
<div key={cred.id}>
|
||||
<span>{cred.name} ({cred.provider})</span>
|
||||
<button onClick={() => handleTest(cred.id)} disabled={isPending}>Test</button>
|
||||
{testResults[cred.id]?.success && <span>Connected!</span>}
|
||||
</div>
|
||||
))}
|
||||
<button onClick={handleCreate}>Add Credential</button>
|
||||
<button onClick={handleMigrate}>Migrate from .env</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Toast notifications**: All mutations show success/error toasts automatically
|
||||
- **i18n integration**: Toast messages use translation keys from `t.apiKeys.*` and `t.common.*`
|
||||
- **Error handling**: Uses `getApiErrorKey()` utility to extract error messages from API responses
|
||||
- **Local test results**: `useTestCredential` stores results in local state (not cached in TanStack Query)
|
||||
- **Migration feedback**: Migration hooks show different toasts based on migrated/skipped/error counts
|
||||
|
|
|
|||
388
frontend/src/lib/hooks/use-credentials.ts
Normal file
388
frontend/src/lib/hooks/use-credentials.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
credentialsApi,
|
||||
CreateCredentialRequest,
|
||||
UpdateCredentialRequest,
|
||||
TestConnectionResult,
|
||||
RegisterModelData,
|
||||
} from '@/lib/api/credentials'
|
||||
import { useToast } from '@/lib/hooks/use-toast'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
||||
import { MODEL_QUERY_KEYS } from '@/lib/hooks/use-models'
|
||||
|
||||
export const CREDENTIAL_QUERY_KEYS = {
|
||||
all: ['credentials'] as const,
|
||||
status: ['credentials', 'status'] as const,
|
||||
envStatus: ['credentials', 'env-status'] as const,
|
||||
byProvider: (provider: string) => ['credentials', 'provider', provider] as const,
|
||||
detail: (id: string) => ['credentials', id] as const,
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the configuration status of all providers
|
||||
*/
|
||||
export function useCredentialStatus() {
|
||||
return useQuery({
|
||||
queryKey: CREDENTIAL_QUERY_KEYS.status,
|
||||
queryFn: () => credentialsApi.getStatus(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get the environment variable status
|
||||
*/
|
||||
export function useEnvStatus() {
|
||||
return useQuery({
|
||||
queryKey: CREDENTIAL_QUERY_KEYS.envStatus,
|
||||
queryFn: () => credentialsApi.getEnvStatus(),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to list all credentials
|
||||
*/
|
||||
export function useCredentials(provider?: string) {
|
||||
return useQuery({
|
||||
queryKey: provider ? CREDENTIAL_QUERY_KEYS.byProvider(provider) : CREDENTIAL_QUERY_KEYS.all,
|
||||
queryFn: () => credentialsApi.list(provider),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to list credentials for a specific provider.
|
||||
* Uses the same list endpoint with provider filter for cache consistency.
|
||||
*/
|
||||
export function useCredentialsByProvider(provider: string) {
|
||||
return useQuery({
|
||||
queryKey: CREDENTIAL_QUERY_KEYS.byProvider(provider),
|
||||
queryFn: () => credentialsApi.list(provider),
|
||||
enabled: !!provider,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get a specific credential
|
||||
*/
|
||||
export function useCredential(credentialId: string) {
|
||||
return useQuery({
|
||||
queryKey: CREDENTIAL_QUERY_KEYS.detail(credentialId),
|
||||
queryFn: () => credentialsApi.get(credentialId),
|
||||
enabled: !!credentialId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to create a new credential
|
||||
*/
|
||||
export function useCreateCredential() {
|
||||
const queryClient = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCredentialRequest) => credentialsApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.configSaveSuccess,
|
||||
})
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: getApiErrorKey(error, t.common.error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update a credential
|
||||
*/
|
||||
export function useUpdateCredential() {
|
||||
const queryClient = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
credentialId,
|
||||
data,
|
||||
}: {
|
||||
credentialId: string
|
||||
data: UpdateCredentialRequest
|
||||
}) => credentialsApi.update(credentialId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.configUpdateSuccess,
|
||||
})
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: getApiErrorKey(error, t.common.error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to delete a credential
|
||||
*/
|
||||
export function useDeleteCredential() {
|
||||
const queryClient = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
credentialId,
|
||||
options,
|
||||
}: {
|
||||
credentialId: string
|
||||
options?: { delete_models?: boolean; migrate_to?: string }
|
||||
}) => credentialsApi.delete(credentialId, options),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.configDeleteSuccess,
|
||||
})
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: getApiErrorKey(error, t.common.error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to test a credential's connection
|
||||
*/
|
||||
export function useTestCredential() {
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
const [testResults, setTestResults] = useState<Record<string, TestConnectionResult>>({})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (credentialId: string) => credentialsApi.test(credentialId),
|
||||
onSuccess: (result, credentialId) => {
|
||||
setTestResults(prev => ({ ...prev, [credentialId]: result }))
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.testSuccess,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: result.message || t.apiKeys.testFailed,
|
||||
variant: 'destructive',
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: getApiErrorKey(error, t.apiKeys.testFailed),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
testCredential: mutation.mutate,
|
||||
testCredentialAsync: mutation.mutateAsync,
|
||||
isPending: mutation.isPending,
|
||||
testResults,
|
||||
clearResult: (credentialId: string) => {
|
||||
setTestResults(prev => {
|
||||
const { [credentialId]: _removed, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to discover models for a credential
|
||||
*/
|
||||
export function useDiscoverModels() {
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (credentialId: string) => credentialsApi.discover(credentialId),
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: getApiErrorKey(error, t.apiKeys.syncFailed),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to register discovered models
|
||||
*/
|
||||
export function useRegisterModels() {
|
||||
const queryClient = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
credentialId,
|
||||
models,
|
||||
}: {
|
||||
credentialId: string
|
||||
models: RegisterModelData[]
|
||||
}) => credentialsApi.registerModels(credentialId, { models }),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
|
||||
|
||||
if (result.created > 0) {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.syncSuccess
|
||||
.replace('{discovered}', (result.created + result.existing).toString())
|
||||
.replace('{new}', result.created.toString()),
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.syncNoNew.replace('{count}', result.existing.toString()),
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: getApiErrorKey(error, t.apiKeys.syncFailed),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to migrate from environment variables
|
||||
*/
|
||||
export function useMigrateFromEnv() {
|
||||
const queryClient = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => credentialsApi.migrateFromEnv(),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.status })
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.envStatus })
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
|
||||
|
||||
const migratedCount = result.migrated.length
|
||||
const errorCount = result.errors?.length ?? 0
|
||||
|
||||
if (errorCount > 0 && migratedCount === 0) {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t.apiKeys.migrationErrors.replace('{count}', errorCount.toString()),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else if (migratedCount > 0 && errorCount > 0) {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: `${t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString())}. ${t.apiKeys.migrationErrors.replace('{count}', errorCount.toString())}`,
|
||||
})
|
||||
} else if (migratedCount > 0) {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString()),
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.migrationNothingToMigrate,
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: getApiErrorKey(error, t.common.error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to migrate from ProviderConfig
|
||||
*/
|
||||
export function useMigrateFromProviderConfig() {
|
||||
const queryClient = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => credentialsApi.migrateFromProviderConfig(),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.all })
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.status })
|
||||
queryClient.invalidateQueries({ queryKey: CREDENTIAL_QUERY_KEYS.envStatus })
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.providers })
|
||||
|
||||
const migratedCount = result.migrated.length
|
||||
const errorCount = result.errors?.length ?? 0
|
||||
|
||||
if (errorCount > 0 && migratedCount === 0) {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: t.apiKeys.migrationErrors.replace('{count}', errorCount.toString()),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else if (migratedCount > 0 && errorCount > 0) {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: `${t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString())}. ${t.apiKeys.migrationErrors.replace('{count}', errorCount.toString())}`,
|
||||
})
|
||||
} else if (migratedCount > 0) {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.migrationSuccess.replace('{count}', migratedCount.toString()),
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.apiKeys.migrationNothingToMigrate,
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: getApiErrorKey(error, t.common.error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { modelsApi } from '@/lib/api/models'
|
||||
import { useToast } from '@/lib/hooks/use-toast'
|
||||
import { useTranslation } from '@/lib/hooks/use-translation'
|
||||
import { getApiErrorKey } from '@/lib/utils/error-handler'
|
||||
import { CreateModelRequest, ModelDefaults } from '@/lib/types/models'
|
||||
import { CreateModelRequest, ModelDefaults, ModelTestResult } from '@/lib/types/models'
|
||||
|
||||
export const MODEL_QUERY_KEYS = {
|
||||
models: ['models'] as const,
|
||||
|
|
@ -61,6 +62,7 @@ export function useDeleteModel() {
|
|||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.models })
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults })
|
||||
queryClient.invalidateQueries({ queryKey: ['credentials'] })
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.models.deleteSuccess,
|
||||
|
|
@ -113,3 +115,85 @@ export function useProviders() {
|
|||
queryFn: () => modelsApi.getProviders(),
|
||||
})
|
||||
}
|
||||
|
||||
export function useAutoAssignDefaults() {
|
||||
const queryClient = useQueryClient()
|
||||
const { toast } = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => modelsApi.autoAssign(),
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: MODEL_QUERY_KEYS.defaults })
|
||||
|
||||
const assignedCount = Object.keys(result.assigned).length
|
||||
const missingCount = result.missing.length
|
||||
|
||||
if (assignedCount > 0) {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.models.autoAssignSuccess.replace('{count}', assignedCount.toString()),
|
||||
})
|
||||
} else if (missingCount > 0) {
|
||||
toast({
|
||||
title: t.common.warning,
|
||||
description: t.models.autoAssignNoModels,
|
||||
variant: 'destructive',
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t.common.success,
|
||||
description: t.models.autoAssignAlreadySet,
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
toast({
|
||||
title: t.common.error,
|
||||
description: getApiErrorKey(error, t.common.error),
|
||||
variant: 'destructive',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useTestModel() {
|
||||
const [testResult, setTestResult] = useState<ModelTestResult | null>(null)
|
||||
const [testedModelName, setTestedModelName] = useState('')
|
||||
const [testingModelId, setTestingModelId] = useState<string | null>(null)
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (modelId: string) => modelsApi.testModel(modelId),
|
||||
onSuccess: (result) => {
|
||||
setTestResult(result)
|
||||
setTestingModelId(null)
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
setTestResult({ success: false, message: msg })
|
||||
setTestingModelId(null)
|
||||
},
|
||||
})
|
||||
|
||||
const testModel = useCallback((modelId: string, modelName: string) => {
|
||||
setTestedModelName(modelName)
|
||||
setTestingModelId(modelId)
|
||||
setTestResult(null)
|
||||
mutation.mutate(modelId)
|
||||
}, [mutation])
|
||||
|
||||
const clearResult = useCallback(() => {
|
||||
setTestResult(null)
|
||||
setTestedModelName('')
|
||||
setTestingModelId(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
testModel,
|
||||
isPending: mutation.isPending,
|
||||
testingModelId,
|
||||
testResult,
|
||||
testedModelName,
|
||||
clearResult,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -910,6 +910,10 @@ export const enUS = {
|
|||
noModelsConfigured: "No models configured",
|
||||
noProviderModelsConfigured: "No {provider} models configured",
|
||||
showMore: "Show {count} more",
|
||||
discoverModels: "Discover Models",
|
||||
noModelsFound: "No models found from this provider",
|
||||
modelType: "Model Type",
|
||||
modelTypeHint: "Select the type for the models you want to add. If you need different types, add them in separate batches.",
|
||||
deleteModel: "Delete Model",
|
||||
deleteModelDesc: "Are you sure you want to delete \"{name}\"? This action cannot be undone.",
|
||||
defaultAssignments: "Default Model Assignments",
|
||||
|
|
@ -954,5 +958,110 @@ export const enUS = {
|
|||
proceedToRebuildPrompt: "Would you like to proceed to the Advanced page to start the rebuild now?",
|
||||
changeModelOnly: "Change Model Only",
|
||||
changeAndRebuild: "Change & Go to Rebuild",
|
||||
}
|
||||
autoAssign: "Auto-assign Defaults",
|
||||
autoAssignDesc: "Automatically assign the best available model for each slot",
|
||||
autoAssigning: "Assigning...",
|
||||
autoAssignSuccess: "{count} default models automatically assigned",
|
||||
autoAssignNoModels: "No models available to assign. Please sync models first.",
|
||||
autoAssignAlreadySet: "All default models are already configured",
|
||||
testModel: "Test Model",
|
||||
testModelSuccess: "Model Test Passed",
|
||||
testModelFailed: "Model Test Failed",
|
||||
testingModel: "Testing model...",
|
||||
searchOrAddModel: "Search or type a model name...",
|
||||
addCustomModel: 'Add "{name}"',
|
||||
},
|
||||
apiKeys: {
|
||||
title: "Configure your AI with your own API keys",
|
||||
description: "Store API keys securely in the database to enable AI providers in Open Notebook.",
|
||||
loadFailed: "Failed to load API keys status",
|
||||
encryptionRequired: "Encryption key not configured",
|
||||
encryptionRequiredDescription: "Set the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable to any secret string to enable storing API keys in the database.",
|
||||
configured: "Configured",
|
||||
notConfigured: "Not configured",
|
||||
sourceDatabase: "Database",
|
||||
sourceEnvironment: "Environment",
|
||||
enterApiKey: "Enter your API key",
|
||||
enterBaseUrl: "Enter the base URL",
|
||||
saveSuccess: "API key saved successfully",
|
||||
deleteSuccess: "API key deleted successfully",
|
||||
fromEnvironmentHint: "This key is set via environment variable. Save a new key to override it in the database.",
|
||||
migrationAvailable: "Environment Variables Detected",
|
||||
migrationDescription: "{count} API key(s) are configured via environment variables and can be migrated to the database for easier management.",
|
||||
migrateToDatabase: "Migrate to Database",
|
||||
migrating: "Migrating...",
|
||||
migrationSuccess: "{count} API key(s) migrated successfully",
|
||||
migrationErrors: "{count} key(s) failed to migrate",
|
||||
migrationNothingToMigrate: "All keys are already in the database",
|
||||
serviceType: "Service Type",
|
||||
serviceLlm: "Language Model (LLM)",
|
||||
serviceEmbedding: "Embedding",
|
||||
serviceStt: "Speech to Text (STT)",
|
||||
serviceTts: "Text to Speech (TTS)",
|
||||
serviceEndpoints: "Service Endpoints (optional)",
|
||||
azureEndpointsHint: "Configure different endpoints for each service type if needed.",
|
||||
endpointPlaceholder: "https://your-resource.openai.azure.com/",
|
||||
openaiCompatibleHint: "Configure an OpenAI-compatible API endpoint. Each service type can have its own configuration.",
|
||||
baseUrlPlaceholder: "https://api.example.com/v1",
|
||||
learnMore: "Learn how to configure API keys →",
|
||||
testConnection: "Test Connection",
|
||||
testing: "Testing...",
|
||||
testSuccess: "Connection successful",
|
||||
testFailed: "Connection test failed",
|
||||
syncModels: "Sync Models",
|
||||
syncing: "Syncing...",
|
||||
syncSuccess: "Discovered {discovered} models, added {new} new",
|
||||
syncNoNew: "Discovered {count} models, all already registered",
|
||||
syncFailed: "Failed to sync models",
|
||||
syncAllModels: "Sync All Providers",
|
||||
syncAllSuccess: "Discovered {discovered} models across all providers, added {new} new",
|
||||
modelsConfigured: "{count} models",
|
||||
noModelsConfigured: "No models",
|
||||
viewModels: "View Models",
|
||||
supportedTypes: "Supported types",
|
||||
typeLanguage: "Language",
|
||||
typeEmbedding: "Embedding",
|
||||
typeTts: "TTS",
|
||||
typeStt: "STT",
|
||||
apiEndpoint: "API Endpoint",
|
||||
getApiKey: "Get API Key",
|
||||
vertexProject: "GCP Project ID",
|
||||
vertexLocation: "Region",
|
||||
vertexCredentials: "Service Account JSON Path",
|
||||
vertexCredentialsHint: "Path to your Google Cloud service account JSON file inside the container.",
|
||||
|
||||
// Multi-config translations
|
||||
configsCount: "{count} configs",
|
||||
configuredMultiple: "Configured",
|
||||
addConfig: "Add Configuration",
|
||||
editConfig: "Edit Configuration",
|
||||
deleteConfig: "Delete Configuration",
|
||||
setAsDefault: "Set as Default",
|
||||
defaultBadge: "Default",
|
||||
defaultDescription: "Default configuration for this provider",
|
||||
configName: "Configuration Name",
|
||||
configNameHint: "A descriptive name for this configuration (e.g., 'Production', 'Development')",
|
||||
baseUrl: "Base URL",
|
||||
baseUrlHint: "Default: {url}",
|
||||
baseUrlOverrideHint: "Only change this if you need to override the provider's default API endpoint.",
|
||||
ollamaApiKeyHint: "Only required for Ollama Cloud. Leave empty for local Ollama.",
|
||||
noConfigs: "No configurations yet",
|
||||
noConfigsHint: "Add a configuration to start using this provider",
|
||||
deleteConfigConfirm: "Are you sure you want to delete '{name}'? This cannot be undone.",
|
||||
setDefaultConfirm: "Set '{name}' as the default configuration?",
|
||||
configSaveSuccess: "Configuration saved successfully",
|
||||
configUpdateSuccess: "Configuration updated successfully",
|
||||
configDeleteSuccess: "Configuration deleted successfully",
|
||||
configSetDefaultSuccess: "Default configuration updated",
|
||||
apiKeyHint: "Enter your API key for this configuration",
|
||||
apiKeyEditHint: "Leave blank to keep the existing API key",
|
||||
},
|
||||
setupBanner: {
|
||||
encryptionRequired: "Encryption key not configured",
|
||||
encryptionRequiredDescription: "Set the OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable to enable secure credential storage.",
|
||||
migrationAvailable: "API key migration available",
|
||||
migrationDescription: "{count} provider(s) have API keys set via environment variables. Migrate them to the database for easier management.",
|
||||
goToSettings: "Go to Settings",
|
||||
viewDocs: "View docs",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -909,6 +909,10 @@ export const itIT = {
|
|||
noModelsConfigured: "Nessun modello configurato",
|
||||
noProviderModelsConfigured: "Nessun modello {provider} configurato",
|
||||
showMore: "Mostra altri {count}",
|
||||
discoverModels: "Scopri Modelli",
|
||||
noModelsFound: "Nessun modello trovato per questo provider",
|
||||
modelType: "Tipo di Modello",
|
||||
modelTypeHint: "Seleziona il tipo per i modelli che vuoi aggiungere. Se hai bisogno di tipi diversi, aggiungili in lotti separati.",
|
||||
deleteModel: "Elimina modello",
|
||||
deleteModelDesc: "Sei sicuro di voler eliminare \"{name}\"? Questa azione non può essere annullata.",
|
||||
defaultAssignments: "Assegnazioni modelli predefiniti",
|
||||
|
|
@ -953,5 +957,104 @@ export const itIT = {
|
|||
proceedToRebuildPrompt: "Vuoi procedere alla pagina avanzate per avviare la ricostruzione ora?",
|
||||
changeModelOnly: "Cambia solo modello",
|
||||
changeAndRebuild: "Cambia e vai a ricostruzione",
|
||||
}
|
||||
testModel: "Testa Modello",
|
||||
testModelSuccess: "Test del Modello Superato",
|
||||
testModelFailed: "Test del Modello Fallito",
|
||||
testingModel: "Test del modello in corso...",
|
||||
searchOrAddModel: "Cerca o digita un nome modello...",
|
||||
addCustomModel: 'Aggiungi "{name}"',
|
||||
},
|
||||
apiKeys: {
|
||||
title: "Configura la tua IA con le tue chiavi API",
|
||||
description: "Salva le chiavi API in modo sicuro nel database per abilitare i provider IA in Open Notebook.",
|
||||
loadFailed: "Impossibile caricare lo stato delle chiavi API",
|
||||
encryptionRequired: "Chiave di crittografia non configurata",
|
||||
encryptionRequiredDescription: "Imposta la variabile d'ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY su una stringa segreta qualsiasi per abilitare il salvataggio delle chiavi API nel database.",
|
||||
configured: "Configurato",
|
||||
notConfigured: "Non configurato",
|
||||
sourceDatabase: "Database",
|
||||
sourceEnvironment: "Variabile d'ambiente",
|
||||
enterApiKey: "Inserisci la tua chiave API",
|
||||
enterBaseUrl: "Inserisci l'URL base",
|
||||
saveSuccess: "Chiave API salvata con successo",
|
||||
deleteSuccess: "Chiave API eliminata con successo",
|
||||
fromEnvironmentHint: "Questa chiave è impostata tramite variabile d'ambiente. Salva una nuova chiave per sovrascriverla nel database.",
|
||||
migrationAvailable: "Variabili d'ambiente rilevate",
|
||||
migrationDescription: "{count} chiave/i API configurata/e tramite variabili d'ambiente. Puoi migrarle nel database per una gestione più semplice.",
|
||||
migrateToDatabase: "Migra nel database",
|
||||
migrating: "Migrazione in corso...",
|
||||
migrationSuccess: "{count} chiave/i API migrata/e con successo",
|
||||
migrationErrors: "{count} chiave/i non migrata/e",
|
||||
migrationNothingToMigrate: "Tutte le chiavi sono già nel database",
|
||||
serviceType: "Tipo di servizio",
|
||||
serviceLlm: "Modello linguistico (LLM)",
|
||||
serviceEmbedding: "Embedding",
|
||||
serviceStt: "Riconoscimento vocale (STT)",
|
||||
serviceTts: "Sintesi vocale (TTS)",
|
||||
serviceEndpoints: "Endpoint dei servizi (opzionale)",
|
||||
azureEndpointsHint: "Se necessario, configura endpoint diversi per ogni tipo di servizio.",
|
||||
endpointPlaceholder: "https://your-resource.openai.azure.com/",
|
||||
openaiCompatibleHint: "Configura un endpoint API compatibile con OpenAI. Ogni tipo di servizio può avere la propria configurazione.",
|
||||
baseUrlPlaceholder: "https://api.example.com/v1",
|
||||
learnMore: "Scopri come configurare le chiavi API →",
|
||||
testConnection: "Testa connessione",
|
||||
testing: "Test in corso...",
|
||||
testSuccess: "Connessione riuscita",
|
||||
testFailed: "Test di connessione fallito",
|
||||
syncModels: "Sincronizza modelli",
|
||||
syncing: "Sincronizzazione...",
|
||||
syncSuccess: "Trovati {discovered} modelli, aggiunti {new} nuovi",
|
||||
syncNoNew: "Trovati {count} modelli, tutti già registrati",
|
||||
syncFailed: "Sincronizzazione modelli fallita",
|
||||
syncAllModels: "Sincronizza tutti i provider",
|
||||
syncAllSuccess: "Trovati {discovered} modelli da tutti i provider, aggiunti {new} nuovi",
|
||||
modelsConfigured: "{count} modelli",
|
||||
noModelsConfigured: "Nessun modello",
|
||||
viewModels: "Visualizza modelli",
|
||||
supportedTypes: "Tipi supportati",
|
||||
typeLanguage: "Linguistico",
|
||||
typeEmbedding: "Embedding",
|
||||
typeTts: "TTS",
|
||||
typeStt: "STT",
|
||||
apiEndpoint: "Endpoint API",
|
||||
getApiKey: "Ottieni chiave API",
|
||||
vertexProject: "ID progetto GCP",
|
||||
vertexLocation: "Regione",
|
||||
vertexCredentials: "Percorso JSON account di servizio",
|
||||
vertexCredentialsHint: "Percorso del file JSON dell'account di servizio Google Cloud all'interno del container.",
|
||||
|
||||
// Traduzioni multi-configurazione
|
||||
configsCount: "{count} configurazioni",
|
||||
configuredMultiple: "Configurato",
|
||||
addConfig: "Aggiungi configurazione",
|
||||
editConfig: "Modifica configurazione",
|
||||
deleteConfig: "Elimina configurazione",
|
||||
setAsDefault: "Imposta come predefinito",
|
||||
defaultBadge: "Predefinito",
|
||||
defaultDescription: "Configurazione predefinita per questo provider",
|
||||
configName: "Nome configurazione",
|
||||
configNameHint: "Un nome descrittivo per questa configurazione (es. 'Produzione', 'Sviluppo')",
|
||||
baseUrl: "URL base",
|
||||
baseUrlHint: "Predefinito: {url}",
|
||||
baseUrlOverrideHint: "Modifica solo se devi sovrascrivere l'endpoint API predefinito del provider.",
|
||||
ollamaApiKeyHint: "Necessaria solo per Ollama Cloud. Lascia vuoto per Ollama locale.",
|
||||
noConfigs: "Nessuna configurazione presente",
|
||||
noConfigsHint: "Aggiungi una configurazione per iniziare a usare questo provider",
|
||||
deleteConfigConfirm: "Sei sicuro di voler eliminare '{name}'? Questa azione non può essere annullata.",
|
||||
setDefaultConfirm: "Impostare '{name}' come configurazione predefinita?",
|
||||
configSaveSuccess: "Configurazione salvata con successo",
|
||||
configUpdateSuccess: "Configurazione aggiornata con successo",
|
||||
configDeleteSuccess: "Configurazione eliminata con successo",
|
||||
configSetDefaultSuccess: "Configurazione predefinita aggiornata",
|
||||
apiKeyHint: "Inserisci la chiave API per questa configurazione",
|
||||
apiKeyEditHint: "Lascia vuoto per mantenere la chiave API esistente",
|
||||
},
|
||||
setupBanner: {
|
||||
encryptionRequired: "Chiave di crittografia non configurata",
|
||||
encryptionRequiredDescription: "Imposta la variabile d'ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY per abilitare l'archiviazione sicura delle credenziali.",
|
||||
migrationAvailable: "Migrazione chiavi API disponibile",
|
||||
migrationDescription: "{count} provider hanno chiavi API impostate tramite variabili d'ambiente. Migrale nel database per una gestione più semplice.",
|
||||
goToSettings: "Vai alle Impostazioni",
|
||||
viewDocs: "Vedi documentazione",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -910,6 +910,10 @@ export const jaJP = {
|
|||
noModelsConfigured: "モデルが設定されていません",
|
||||
noProviderModelsConfigured: "{provider}モデルが設定されていません",
|
||||
showMore: "さらに{count}件表示",
|
||||
discoverModels: "モデルを検出",
|
||||
noModelsFound: "このプロバイダーからモデルが見つかりません",
|
||||
modelType: "モデルタイプ",
|
||||
modelTypeHint: "追加するモデルのタイプを選択してください。異なるタイプが必要な場合は、別々のバッチで追加してください。",
|
||||
deleteModel: "モデルを削除",
|
||||
deleteModelDesc: "「{name}」を削除しますか?この操作は元に戻せません。",
|
||||
defaultAssignments: "デフォルトモデル割り当て",
|
||||
|
|
@ -954,5 +958,110 @@ export const jaJP = {
|
|||
proceedToRebuildPrompt: "今すぐ詳細設定ページで再構築を開始しますか?",
|
||||
changeModelOnly: "モデルのみ変更",
|
||||
changeAndRebuild: "変更して再構築へ",
|
||||
}
|
||||
autoAssign: "デフォルトを自動割り当て",
|
||||
autoAssignDesc: "各スロットに最適なモデルを自動的に割り当てます",
|
||||
autoAssigning: "割り当て中...",
|
||||
autoAssignSuccess: "{count}件のデフォルトモデルを自動的に割り当てました",
|
||||
autoAssignNoModels: "割り当て可能なモデルがありません。先にモデルを同期してください。",
|
||||
autoAssignAlreadySet: "すべてのデフォルトモデルは既に設定されています",
|
||||
testModel: "モデルをテスト",
|
||||
testModelSuccess: "モデルテスト成功",
|
||||
testModelFailed: "モデルテスト失敗",
|
||||
testingModel: "モデルをテスト中...",
|
||||
searchOrAddModel: "検索またはモデル名を入力...",
|
||||
addCustomModel: '"{name}" を追加',
|
||||
},
|
||||
apiKeys: {
|
||||
title: "独自のAPIキーでAIを設定",
|
||||
description: "APIキーをデータベースに安全に保存し、Open NotebookでAIプロバイダーを有効にします。",
|
||||
loadFailed: "APIキーのステータスの読み込みに失敗しました",
|
||||
encryptionRequired: "暗号化キーが設定されていません",
|
||||
encryptionRequiredDescription: "OPEN_NOTEBOOK_ENCRYPTION_KEY 環境変数に任意の秘密文字列を設定して、データベースへのAPIキーの保存を有効にしてください。",
|
||||
configured: "設定済み",
|
||||
notConfigured: "未設定",
|
||||
sourceDatabase: "データベース",
|
||||
sourceEnvironment: "環境変数",
|
||||
enterApiKey: "APIキーを入力してください",
|
||||
enterBaseUrl: "ベースURLを入力してください",
|
||||
saveSuccess: "APIキーを保存しました",
|
||||
deleteSuccess: "APIキーを削除しました",
|
||||
fromEnvironmentHint: "このキーは環境変数で設定されています。新しいキーを保存するとデータベースで上書きされます。",
|
||||
migrationAvailable: "環境変数を検出",
|
||||
migrationDescription: "{count}個のAPIキーが環境変数で設定されています。管理を容易にするためにデータベースに移行できます。",
|
||||
migrateToDatabase: "データベースに移行",
|
||||
migrating: "移行中...",
|
||||
migrationSuccess: "{count}個のAPIキーを移行しました",
|
||||
migrationErrors: "{count}個のキーの移行に失敗しました",
|
||||
migrationNothingToMigrate: "すべてのキーはすでにデータベースにあります",
|
||||
serviceType: "サービスタイプ",
|
||||
serviceLlm: "言語モデル(LLM)",
|
||||
serviceEmbedding: "Embedding",
|
||||
serviceStt: "音声認識(STT)",
|
||||
serviceTts: "音声合成(TTS)",
|
||||
serviceEndpoints: "サービスエンドポイント(任意)",
|
||||
azureEndpointsHint: "必要に応じて、各サービスタイプに異なるエンドポイントを設定します。",
|
||||
endpointPlaceholder: "https://your-resource.openai.azure.com/",
|
||||
openaiCompatibleHint: "OpenAI互換のAPIエンドポイントを設定します。各サービスタイプに独自の設定が可能です。",
|
||||
baseUrlPlaceholder: "https://api.example.com/v1",
|
||||
learnMore: "APIキーの設定方法を確認 →",
|
||||
testConnection: "接続テスト",
|
||||
testing: "テスト中...",
|
||||
testSuccess: "接続成功",
|
||||
testFailed: "接続テストに失敗",
|
||||
syncModels: "モデル同期",
|
||||
syncing: "同期中...",
|
||||
syncSuccess: "{discovered} モデルを発見、{new} 個を新規追加",
|
||||
syncNoNew: "{count} モデルを発見、すべて登録済み",
|
||||
syncFailed: "モデルの同期に失敗",
|
||||
syncAllModels: "全プロバイダーを同期",
|
||||
syncAllSuccess: "全プロバイダーで {discovered} モデルを発見、{new} 個を新規追加",
|
||||
modelsConfigured: "{count} モデル",
|
||||
noModelsConfigured: "モデルなし",
|
||||
viewModels: "モデルを表示",
|
||||
supportedTypes: "対応タイプ",
|
||||
typeLanguage: "言語",
|
||||
typeEmbedding: "埋め込み",
|
||||
typeTts: "TTS",
|
||||
typeStt: "STT",
|
||||
apiEndpoint: "APIエンドポイント",
|
||||
getApiKey: "APIキーを取得",
|
||||
vertexProject: "GCPプロジェクトID",
|
||||
vertexLocation: "リージョン",
|
||||
vertexCredentials: "サービスアカウントJSONパス",
|
||||
vertexCredentialsHint: "コンテナ内のGoogle Cloudサービスアカウント JSON ファイルへのパス。",
|
||||
|
||||
// Multi-config translations
|
||||
configsCount: "{count} 設定",
|
||||
configuredMultiple: "設定済み",
|
||||
addConfig: "設定を追加",
|
||||
editConfig: "設定を編集",
|
||||
deleteConfig: "設定を削除",
|
||||
setAsDefault: "デフォルトに設定",
|
||||
defaultBadge: "デフォルト",
|
||||
defaultDescription: "このプロバイダーのデフォルト設定",
|
||||
configName: "設定名",
|
||||
configNameHint: "この設定の説明的な名前(例:本番環境、開発環境)",
|
||||
baseUrl: "ベースURL",
|
||||
baseUrlHint: "デフォルト:{url}",
|
||||
baseUrlOverrideHint: "プロバイダーのデフォルト API エンドポイントを上書きする場合のみ変更してください。",
|
||||
ollamaApiKeyHint: "Ollama Cloud でのみ必要です。ローカル Ollama の場合は空のままにしてください。",
|
||||
noConfigs: "設定がありません",
|
||||
noConfigsHint: "このプロバイダーの使用を開始するには設定を追加してください",
|
||||
deleteConfigConfirm: "「{name}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
setDefaultConfirm: "「{name}」をデフォルト設定にしますか?",
|
||||
configSaveSuccess: "設定が正常に保存されました",
|
||||
configUpdateSuccess: "設定が正常に変更されました",
|
||||
configDeleteSuccess: "設定が正常に削除されました",
|
||||
configSetDefaultSuccess: "デフォルト設定が更新されました",
|
||||
apiKeyHint: "この設定のAPIキーを入力してください",
|
||||
apiKeyEditHint: "既存のAPIキーを維持するには空白のままにしてください",
|
||||
},
|
||||
setupBanner: {
|
||||
encryptionRequired: "暗号化キーが設定されていません",
|
||||
encryptionRequiredDescription: "OPEN_NOTEBOOK_ENCRYPTION_KEY 環境変数を設定して、安全な認証情報の保存を有効にしてください。",
|
||||
migrationAvailable: "APIキーの移行が可能です",
|
||||
migrationDescription: "{count} 個のプロバイダーのAPIキーが環境変数で設定されています。管理を容易にするためにデータベースに移行してください。",
|
||||
goToSettings: "設定へ移動",
|
||||
viewDocs: "ドキュメントを見る",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -910,6 +910,10 @@ export const ptBR = {
|
|||
noModelsConfigured: "Nenhum modelo configurado",
|
||||
noProviderModelsConfigured: "Nenhum modelo {provider} configurado",
|
||||
showMore: "Mostrar mais {count}",
|
||||
discoverModels: "Descobrir Modelos",
|
||||
noModelsFound: "Nenhum modelo encontrado para este provedor",
|
||||
modelType: "Tipo do Modelo",
|
||||
modelTypeHint: "Selecione o tipo para os modelos que deseja adicionar. Se precisar de tipos diferentes, adicione em lotes separados.",
|
||||
deleteModel: "Excluir Modelo",
|
||||
deleteModelDesc: "Tem certeza que deseja excluir \"{name}\"? Esta ação não pode ser desfeita.",
|
||||
defaultAssignments: "Atribuições de Modelo Padrão",
|
||||
|
|
@ -954,5 +958,110 @@ export const ptBR = {
|
|||
proceedToRebuildPrompt: "Gostaria de ir para a página Avançado para iniciar a reconstrução agora?",
|
||||
changeModelOnly: "Apenas Alterar Modelo",
|
||||
changeAndRebuild: "Alterar e Ir para Reconstrução",
|
||||
}
|
||||
autoAssign: "Atribuir Automaticamente",
|
||||
autoAssignDesc: "Atribuir automaticamente o melhor modelo disponível para cada slot",
|
||||
autoAssigning: "Atribuindo...",
|
||||
autoAssignSuccess: "{count} modelos padrão atribuídos automaticamente",
|
||||
autoAssignNoModels: "Nenhum modelo disponível para atribuir. Por favor, sincronize os modelos primeiro.",
|
||||
autoAssignAlreadySet: "Todos os modelos padrão já estão configurados",
|
||||
testModel: "Testar Modelo",
|
||||
testModelSuccess: "Teste do Modelo Passou",
|
||||
testModelFailed: "Teste do Modelo Falhou",
|
||||
testingModel: "Testando modelo...",
|
||||
searchOrAddModel: "Pesquisar ou digitar nome do modelo...",
|
||||
addCustomModel: 'Adicionar "{name}"',
|
||||
},
|
||||
apiKeys: {
|
||||
title: "Configure sua IA com suas próprias chaves de API",
|
||||
description: "Armazene chaves de API com segurança no banco de dados para habilitar provedores de IA no Open Notebook.",
|
||||
loadFailed: "Falha ao carregar status das chaves de API",
|
||||
encryptionRequired: "Chave de criptografia não configurada",
|
||||
encryptionRequiredDescription: "Configure a variável de ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY com qualquer string secreta para armazenar chaves de API no banco de dados.",
|
||||
configured: "Configurado",
|
||||
notConfigured: "Não configurado",
|
||||
sourceDatabase: "Banco de dados",
|
||||
sourceEnvironment: "Ambiente",
|
||||
enterApiKey: "Digite sua chave de API",
|
||||
enterBaseUrl: "Digite a URL base",
|
||||
saveSuccess: "Chave de API salva com sucesso",
|
||||
deleteSuccess: "Chave de API excluída com sucesso",
|
||||
fromEnvironmentHint: "Esta chave é definida via variável de ambiente. Salve uma nova chave para sobrescrevê-la no banco de dados.",
|
||||
migrationAvailable: "Variáveis de Ambiente Detectadas",
|
||||
migrationDescription: "{count} chave(s) de API estão configuradas via variáveis de ambiente e podem ser migradas para o banco de dados para facilitar o gerenciamento.",
|
||||
migrateToDatabase: "Migrar para Banco de Dados",
|
||||
migrating: "Migrando...",
|
||||
migrationSuccess: "{count} chave(s) de API migrada(s) com sucesso",
|
||||
migrationErrors: "{count} chave(s) falhou ao migrar",
|
||||
migrationNothingToMigrate: "Todas as chaves já estão no banco de dados",
|
||||
serviceType: "Tipo de Serviço",
|
||||
serviceLlm: "Modelo de Linguagem (LLM)",
|
||||
serviceEmbedding: "Embedding",
|
||||
serviceStt: "Speech to Text (STT)",
|
||||
serviceTts: "Text to Speech (TTS)",
|
||||
serviceEndpoints: "Endpoints de Serviço (opcional)",
|
||||
azureEndpointsHint: "Configure endpoints diferentes para cada tipo de serviço se necessário.",
|
||||
endpointPlaceholder: "https://seu-recurso.openai.azure.com/",
|
||||
openaiCompatibleHint: "Configure um endpoint de API compatível com OpenAI. Cada tipo de serviço pode ter sua própria configuração.",
|
||||
baseUrlPlaceholder: "https://api.exemplo.com/v1",
|
||||
learnMore: "Saiba como configurar chaves de API →",
|
||||
testConnection: "Testar Conexão",
|
||||
testing: "Testando...",
|
||||
testSuccess: "Conexão bem-sucedida",
|
||||
testFailed: "Falha no teste de conexão",
|
||||
syncModels: "Sincronizar Modelos",
|
||||
syncing: "Sincronizando...",
|
||||
syncSuccess: "Descobertos {discovered} modelos, {new} novos adicionados",
|
||||
syncNoNew: "Descobertos {count} modelos, todos já registrados",
|
||||
syncFailed: "Falha ao sincronizar modelos",
|
||||
syncAllModels: "Sincronizar Todos os Provedores",
|
||||
syncAllSuccess: "Descobertos {discovered} modelos em todos os provedores, {new} novos adicionados",
|
||||
modelsConfigured: "{count} modelos",
|
||||
noModelsConfigured: "Sem modelos",
|
||||
viewModels: "Ver Modelos",
|
||||
supportedTypes: "Tipos suportados",
|
||||
typeLanguage: "Linguagem",
|
||||
typeEmbedding: "Embedding",
|
||||
typeTts: "TTS",
|
||||
typeStt: "STT",
|
||||
apiEndpoint: "Endpoint da API",
|
||||
getApiKey: "Obter Chave de API",
|
||||
vertexProject: "ID do Projeto GCP",
|
||||
vertexLocation: "Região",
|
||||
vertexCredentials: "Caminho do JSON da Conta de Serviço",
|
||||
vertexCredentialsHint: "Caminho para o arquivo JSON da conta de serviço do Google Cloud dentro do contêiner.",
|
||||
|
||||
// Multi-config translations
|
||||
configsCount: "{count} configurações",
|
||||
configuredMultiple: "Configurado",
|
||||
addConfig: "Adicionar Configuração",
|
||||
editConfig: "Editar Configuração",
|
||||
deleteConfig: "Excluir Configuração",
|
||||
setAsDefault: "Definir como Padrão",
|
||||
defaultBadge: "Padrão",
|
||||
defaultDescription: "Configuração padrão para este provedor",
|
||||
configName: "Nome da Configuração",
|
||||
configNameHint: "Um nome descritivo para esta configuração (ex.: 'Produção', 'Desenvolvimento')",
|
||||
baseUrl: "URL Base",
|
||||
baseUrlHint: "Padrão: {url}",
|
||||
baseUrlOverrideHint: "Altere apenas se precisar sobrescrever o endpoint padrão do provedor.",
|
||||
ollamaApiKeyHint: "Necessária apenas para Ollama Cloud. Deixe vazio para Ollama local.",
|
||||
noConfigs: "Sem configurações ainda",
|
||||
noConfigsHint: "Adicione uma configuração para começar a usar este provedor",
|
||||
deleteConfigConfirm: "Tem certeza de que deseja excluir '{name}'? Esta ação não pode ser desfeita.",
|
||||
setDefaultConfirm: "Definir '{name}' como a configuração padrão?",
|
||||
configSaveSuccess: "Configuração salva com sucesso",
|
||||
configUpdateSuccess: "Configuração atualizada com sucesso",
|
||||
configDeleteSuccess: "Configuração excluída com sucesso",
|
||||
configSetDefaultSuccess: "Configuração padrão atualizada",
|
||||
apiKeyHint: "Digite sua chave de API para esta configuração",
|
||||
apiKeyEditHint: "Deixe em branco para manter a chave de API existente",
|
||||
},
|
||||
setupBanner: {
|
||||
encryptionRequired: "Chave de criptografia não configurada",
|
||||
encryptionRequiredDescription: "Configure a variável de ambiente OPEN_NOTEBOOK_ENCRYPTION_KEY para habilitar o armazenamento seguro de credenciais.",
|
||||
migrationAvailable: "Migração de chaves de API disponível",
|
||||
migrationDescription: "{count} provedor(es) possuem chaves de API definidas por variáveis de ambiente. Migre-as para o banco de dados para facilitar o gerenciamento.",
|
||||
goToSettings: "Ir para Configurações",
|
||||
viewDocs: "Ver documentação",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -910,6 +910,10 @@ export const ruRU = {
|
|||
noModelsConfigured: "Модели не настроены",
|
||||
noProviderModelsConfigured: "Модели {provider} не настроены",
|
||||
showMore: "Показать ещё {count}",
|
||||
discoverModels: "Обнаружение моделей",
|
||||
noModelsFound: "Модели от этого провайдера не найдены",
|
||||
modelType: "Тип модели",
|
||||
modelTypeHint: "Выберите тип для добавляемых моделей. Если нужны разные типы, добавляйте их отдельными партиями.",
|
||||
deleteModel: "Удалить модель",
|
||||
deleteModelDesc: "Вы уверены, что хотите удалить «{name}»? Это действие нельзя отменить.",
|
||||
defaultAssignments: "Назначение моделей по умолчанию",
|
||||
|
|
@ -954,5 +958,104 @@ export const ruRU = {
|
|||
proceedToRebuildPrompt: "Хотите перейти на страницу «Дополнительно», чтобы начать пересоздание сейчас?",
|
||||
changeModelOnly: "Только изменить модель",
|
||||
changeAndRebuild: "Изменить и перейти к пересозданию",
|
||||
}
|
||||
testModel: "Тестировать модель",
|
||||
testModelSuccess: "Тест модели пройден",
|
||||
testModelFailed: "Тест модели не пройден",
|
||||
testingModel: "Тестирование модели...",
|
||||
searchOrAddModel: "Поиск или введите имя модели...",
|
||||
addCustomModel: 'Добавить "{name}"',
|
||||
},
|
||||
apiKeys: {
|
||||
title: "Настройте ИИ с помощью собственных API-ключей",
|
||||
description: "Храните API-ключи в базе данных для безопасного подключения провайдеров ИИ в Open Notebook.",
|
||||
loadFailed: "Не удалось загрузить статус API-ключей",
|
||||
encryptionRequired: "Ключ шифрования не настроен",
|
||||
encryptionRequiredDescription: "Установите переменную окружения OPEN_NOTEBOOK_ENCRYPTION_KEY в любую секретную строку для хранения API-ключей в базе данных.",
|
||||
configured: "Настроено",
|
||||
notConfigured: "Не настроено",
|
||||
sourceDatabase: "База данных",
|
||||
sourceEnvironment: "Переменная окружения",
|
||||
enterApiKey: "Введите ваш API-ключ",
|
||||
enterBaseUrl: "Введите базовый URL",
|
||||
saveSuccess: "API-ключ успешно сохранён",
|
||||
deleteSuccess: "API-ключ успешно удалён",
|
||||
fromEnvironmentHint: "Этот ключ задан через переменную окружения. Сохраните новый ключ, чтобы переопределить его в базе данных.",
|
||||
migrationAvailable: "Обнаружены переменные окружения",
|
||||
migrationDescription: "{count} API-ключ(ей) настроено через переменные окружения и может быть перенесено в базу данных для удобного управления.",
|
||||
migrateToDatabase: "Перенести в базу данных",
|
||||
migrating: "Перенос...",
|
||||
migrationSuccess: "{count} API-ключ(ей) успешно перенесено",
|
||||
migrationErrors: "{count} ключ(ей) не удалось перенести",
|
||||
migrationNothingToMigrate: "Все ключи уже находятся в базе данных",
|
||||
serviceType: "Тип сервиса",
|
||||
serviceLlm: "Языковая модель (LLM)",
|
||||
serviceEmbedding: "Эмбеддинг",
|
||||
serviceStt: "Распознавание речи (STT)",
|
||||
serviceTts: "Синтез речи (TTS)",
|
||||
serviceEndpoints: "Эндпоинты сервисов (необязательно)",
|
||||
azureEndpointsHint: "При необходимости настройте отдельные эндпоинты для каждого типа сервиса.",
|
||||
endpointPlaceholder: "https://your-resource.openai.azure.com/",
|
||||
openaiCompatibleHint: "Настройте совместимый с OpenAI API-эндпоинт. Каждый тип сервиса может иметь собственную конфигурацию.",
|
||||
baseUrlPlaceholder: "https://api.example.com/v1",
|
||||
learnMore: "Узнайте, как настроить API-ключи →",
|
||||
testConnection: "Проверить подключение",
|
||||
testing: "Проверка...",
|
||||
testSuccess: "Подключение успешно",
|
||||
testFailed: "Проверка подключения не удалась",
|
||||
syncModels: "Синхронизировать модели",
|
||||
syncing: "Синхронизация...",
|
||||
syncSuccess: "Обнаружено {discovered} моделей, добавлено {new} новых",
|
||||
syncNoNew: "Обнаружено {count} моделей, все уже зарегистрированы",
|
||||
syncFailed: "Не удалось синхронизировать модели",
|
||||
syncAllModels: "Синхронизировать всех провайдеров",
|
||||
syncAllSuccess: "Обнаружено {discovered} моделей у всех провайдеров, добавлено {new} новых",
|
||||
modelsConfigured: "{count} моделей",
|
||||
noModelsConfigured: "Нет моделей",
|
||||
viewModels: "Посмотреть модели",
|
||||
supportedTypes: "Поддерживаемые типы",
|
||||
typeLanguage: "Языковая",
|
||||
typeEmbedding: "Эмбеддинг",
|
||||
typeTts: "TTS",
|
||||
typeStt: "STT",
|
||||
apiEndpoint: "API-эндпоинт",
|
||||
getApiKey: "Получить API-ключ",
|
||||
vertexProject: "ID проекта GCP",
|
||||
vertexLocation: "Регион",
|
||||
vertexCredentials: "Путь к JSON сервисного аккаунта",
|
||||
vertexCredentialsHint: "Путь к JSON-файлу сервисного аккаунта Google Cloud внутри контейнера.",
|
||||
|
||||
// Мультиконфигурация
|
||||
configsCount: "{count} конфигураций",
|
||||
configuredMultiple: "Настроено",
|
||||
addConfig: "Добавить конфигурацию",
|
||||
editConfig: "Редактировать конфигурацию",
|
||||
deleteConfig: "Удалить конфигурацию",
|
||||
setAsDefault: "Установить по умолчанию",
|
||||
defaultBadge: "По умолчанию",
|
||||
defaultDescription: "Конфигурация по умолчанию для этого провайдера",
|
||||
configName: "Название конфигурации",
|
||||
configNameHint: "Описательное название для этой конфигурации (например, «Продакшн», «Разработка»)",
|
||||
baseUrl: "Базовый URL",
|
||||
baseUrlHint: "По умолчанию: {url}",
|
||||
baseUrlOverrideHint: "Изменяйте только если нужно переопределить стандартную конечную точку API провайдера.",
|
||||
ollamaApiKeyHint: "Требуется только для Ollama Cloud. Оставьте пустым для локальной Ollama.",
|
||||
noConfigs: "Конфигурации ещё не созданы",
|
||||
noConfigsHint: "Добавьте конфигурацию, чтобы начать использовать этого провайдера",
|
||||
deleteConfigConfirm: "Вы уверены, что хотите удалить «{name}»? Это действие необратимо.",
|
||||
setDefaultConfirm: "Установить «{name}» как конфигурацию по умолчанию?",
|
||||
configSaveSuccess: "Конфигурация успешно сохранена",
|
||||
configUpdateSuccess: "Конфигурация успешно обновлена",
|
||||
configDeleteSuccess: "Конфигурация успешно удалена",
|
||||
configSetDefaultSuccess: "Конфигурация по умолчанию обновлена",
|
||||
apiKeyHint: "Введите API-ключ для этой конфигурации",
|
||||
apiKeyEditHint: "Оставьте пустым, чтобы сохранить текущий API-ключ",
|
||||
},
|
||||
setupBanner: {
|
||||
encryptionRequired: "Ключ шифрования не настроен",
|
||||
encryptionRequiredDescription: "Установите переменную окружения OPEN_NOTEBOOK_ENCRYPTION_KEY для безопасного хранения учётных данных.",
|
||||
migrationAvailable: "Доступна миграция API-ключей",
|
||||
migrationDescription: "{count} провайдер(ов) имеют API-ключи, заданные через переменные окружения. Перенесите их в базу данных для удобного управления.",
|
||||
goToSettings: "Перейти к настройкам",
|
||||
viewDocs: "Документация",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -910,6 +910,10 @@ export const zhCN = {
|
|||
noModelsConfigured: "未配置模型",
|
||||
noProviderModelsConfigured: "未配置 {provider} 模型",
|
||||
showMore: "显示更多 ({count})",
|
||||
discoverModels: "发现模型",
|
||||
noModelsFound: "未从此提供商找到模型",
|
||||
modelType: "模型类型",
|
||||
modelTypeHint: "选择要添加的模型类型。如果需要不同类型,请分批添加。",
|
||||
deleteModel: "删除模型",
|
||||
deleteModelDesc: "确定要删除模型 “{name}” 吗?该操作无法撤销。",
|
||||
defaultAssignments: "默认模型分配",
|
||||
|
|
@ -954,5 +958,110 @@ export const zhCN = {
|
|||
proceedToRebuildPrompt: "您想现在前往“高级设置”页面开始重建索引吗?",
|
||||
changeModelOnly: "仅更改模型",
|
||||
changeAndRebuild: "更改并前往重建",
|
||||
autoAssign: "自动分配默认值",
|
||||
autoAssignDesc: "为每个槽位自动分配最佳可用模型",
|
||||
autoAssigning: "正在分配...",
|
||||
autoAssignSuccess: "已自动分配 {count} 个默认模型",
|
||||
autoAssignNoModels: "没有可分配的模型。请先同步模型。",
|
||||
autoAssignAlreadySet: "所有默认模型已配置",
|
||||
testModel: "测试模型",
|
||||
testModelSuccess: "模型测试通过",
|
||||
testModelFailed: "模型测试失败",
|
||||
testingModel: "正在测试模型...",
|
||||
searchOrAddModel: "搜索或输入模型名称...",
|
||||
addCustomModel: '添加 "{name}"',
|
||||
},
|
||||
apiKeys: {
|
||||
title: "使用您自己的 API 密钥配置 AI",
|
||||
description: "将 API 密钥安全地存储在数据库中,以在 Open Notebook 中启用 AI 服务商。",
|
||||
loadFailed: "加载 API 密钥状态失败",
|
||||
encryptionRequired: "未配置加密密钥",
|
||||
encryptionRequiredDescription: "请将 OPEN_NOTEBOOK_ENCRYPTION_KEY 环境变量设置为任意密钥字符串,以启用将 API 密钥存储到数据库。",
|
||||
configured: "已配置",
|
||||
notConfigured: "未配置",
|
||||
sourceDatabase: "数据库",
|
||||
sourceEnvironment: "环境变量",
|
||||
enterApiKey: "输入您的 API 密钥",
|
||||
enterBaseUrl: "输入基础 URL",
|
||||
saveSuccess: "API 密钥保存成功",
|
||||
deleteSuccess: "API 密钥删除成功",
|
||||
fromEnvironmentHint: "此密钥通过环境变量设置。保存新密钥将在数据库中覆盖它。",
|
||||
migrationAvailable: "检测到环境变量",
|
||||
migrationDescription: "{count} 个 API 密钥通过环境变量配置,可以迁移到数据库以便于管理。",
|
||||
migrateToDatabase: "迁移到数据库",
|
||||
migrating: "迁移中...",
|
||||
migrationSuccess: "{count} 个 API 密钥迁移成功",
|
||||
migrationErrors: "{count} 个密钥迁移失败",
|
||||
migrationNothingToMigrate: "所有密钥已在数据库中",
|
||||
serviceType: "服务类型",
|
||||
serviceLlm: "语言模型 (LLM)",
|
||||
serviceEmbedding: "嵌入",
|
||||
serviceStt: "语音转文字 (STT)",
|
||||
serviceTts: "文字转语音 (TTS)",
|
||||
serviceEndpoints: "服务端点(可选)",
|
||||
azureEndpointsHint: "如有需要,为每种服务类型配置不同的端点。",
|
||||
endpointPlaceholder: "https://your-resource.openai.azure.com/",
|
||||
openaiCompatibleHint: "配置 OpenAI 兼容的 API 端点。每种服务类型可以有自己的配置。",
|
||||
baseUrlPlaceholder: "https://api.example.com/v1",
|
||||
learnMore: "了解如何配置 API 密钥 →",
|
||||
testConnection: "测试连接",
|
||||
testing: "测试中...",
|
||||
testSuccess: "连接成功",
|
||||
testFailed: "连接测试失败",
|
||||
syncModels: "同步模型",
|
||||
syncing: "同步中...",
|
||||
syncSuccess: "发现 {discovered} 个模型,新增 {new} 个",
|
||||
syncNoNew: "发现 {count} 个模型,全部已注册",
|
||||
syncFailed: "同步模型失败",
|
||||
syncAllModels: "同步所有提供商",
|
||||
syncAllSuccess: "在所有提供商中发现 {discovered} 个模型,新增 {new} 个",
|
||||
modelsConfigured: "{count} 个模型",
|
||||
noModelsConfigured: "无模型",
|
||||
viewModels: "查看模型",
|
||||
supportedTypes: "支持的类型",
|
||||
typeLanguage: "语言",
|
||||
typeEmbedding: "嵌入",
|
||||
typeTts: "TTS",
|
||||
typeStt: "STT",
|
||||
apiEndpoint: "API 端点",
|
||||
getApiKey: "获取 API 密钥",
|
||||
vertexProject: "GCP 项目 ID",
|
||||
vertexLocation: "区域",
|
||||
vertexCredentials: "服务账户 JSON 路径",
|
||||
vertexCredentialsHint: "容器内 Google Cloud 服务账户 JSON 文件的路径。",
|
||||
|
||||
// Multi-config translations
|
||||
configsCount: "{count} 个配置",
|
||||
configuredMultiple: "已配置",
|
||||
addConfig: "添加配置",
|
||||
editConfig: "编辑配置",
|
||||
deleteConfig: "删除配置",
|
||||
setAsDefault: "设为默认",
|
||||
defaultBadge: "默认",
|
||||
defaultDescription: "此提供商的默认配置",
|
||||
configName: "配置名称",
|
||||
configNameHint: "此配置的描述性名称(例如:'生产环境'、'开发环境')",
|
||||
baseUrl: "基础 URL",
|
||||
baseUrlHint: "默认:{url}",
|
||||
baseUrlOverrideHint: "仅在需要覆盖提供商默认 API 端点时更改此项。",
|
||||
ollamaApiKeyHint: "仅 Ollama Cloud 需要 API 密钥。本地 Ollama 请留空。",
|
||||
noConfigs: "暂无配置",
|
||||
noConfigsHint: "添加配置以开始使用此提供商",
|
||||
deleteConfigConfirm: "确定要删除 '{name}' 吗?此操作无法撤销。",
|
||||
setDefaultConfirm: "将 '{name}' 设为默认配置?",
|
||||
configSaveSuccess: "配置保存成功",
|
||||
configUpdateSuccess: "配置更新成功",
|
||||
configDeleteSuccess: "配置删除成功",
|
||||
configSetDefaultSuccess: "默认配置已更新",
|
||||
apiKeyHint: "输入此配置的 API 密钥",
|
||||
apiKeyEditHint: "留空以保留现有 API 密钥",
|
||||
},
|
||||
setupBanner: {
|
||||
encryptionRequired: "未配置加密密钥",
|
||||
encryptionRequiredDescription: "请设置 OPEN_NOTEBOOK_ENCRYPTION_KEY 环境变量以启用安全凭据存储。",
|
||||
migrationAvailable: "API 密钥迁移可用",
|
||||
migrationDescription: "{count} 个服务商的 API 密钥通过环境变量设置。将它们迁移到数据库以便于管理。",
|
||||
goToSettings: "前往设置",
|
||||
viewDocs: "查看文档",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -910,6 +910,10 @@ export const zhTW = {
|
|||
noModelsConfigured: "未設定模型",
|
||||
noProviderModelsConfigured: "未設定 {provider} 模型",
|
||||
showMore: "顯示更多 ({count})",
|
||||
discoverModels: "探索模型",
|
||||
noModelsFound: "未從此提供商找到模型",
|
||||
modelType: "模型類型",
|
||||
modelTypeHint: "選擇要新增的模型類型。如果需要不同類型,請分批新增。",
|
||||
deleteModel: "刪除模型",
|
||||
deleteModelDesc: "確定要刪除模型 “{name}” 嗎?該操作無法撤銷。",
|
||||
defaultAssignments: "預設模型分配",
|
||||
|
|
@ -954,5 +958,110 @@ export const zhTW = {
|
|||
proceedToRebuildPrompt: "您想現在前往“進階設定”頁面開始重建索引嗎?",
|
||||
changeModelOnly: "僅更改模型",
|
||||
changeAndRebuild: "更改並前往重建",
|
||||
autoAssign: "自動指派預設值",
|
||||
autoAssignDesc: "為每個插槽自動指派最佳可用模型",
|
||||
autoAssigning: "正在指派...",
|
||||
autoAssignSuccess: "已自動指派 {count} 個預設模型",
|
||||
autoAssignNoModels: "沒有可指派的模型。請先同步模型。",
|
||||
autoAssignAlreadySet: "所有預設模型已設定",
|
||||
testModel: "測試模型",
|
||||
testModelSuccess: "模型測試通過",
|
||||
testModelFailed: "模型測試失敗",
|
||||
testingModel: "正在測試模型...",
|
||||
searchOrAddModel: "搜尋或輸入模型名稱...",
|
||||
addCustomModel: '新增 "{name}"',
|
||||
},
|
||||
apiKeys: {
|
||||
title: "使用您自己的 API 金鑰設定 AI",
|
||||
description: "將 API 金鑰安全地儲存在資料庫中,以在 Open Notebook 中啟用 AI 服務商。",
|
||||
loadFailed: "載入 API 金鑰狀態失敗",
|
||||
encryptionRequired: "未設定加密金鑰",
|
||||
encryptionRequiredDescription: "請將 OPEN_NOTEBOOK_ENCRYPTION_KEY 環境變數設定為任意密鑰字串,以啟用將 API 金鑰儲存至資料庫。",
|
||||
configured: "已設定",
|
||||
notConfigured: "未設定",
|
||||
sourceDatabase: "資料庫",
|
||||
sourceEnvironment: "環境變數",
|
||||
enterApiKey: "輸入您的 API 金鑰",
|
||||
enterBaseUrl: "輸入基礎 URL",
|
||||
saveSuccess: "API 金鑰儲存成功",
|
||||
deleteSuccess: "API 金鑰刪除成功",
|
||||
fromEnvironmentHint: "此金鑰通過環境變數設定。儲存新金鑰將在資料庫中覆蓋它。",
|
||||
migrationAvailable: "偵測到環境變數",
|
||||
migrationDescription: "{count} 個 API 金鑰通過環境變數設定,可以遷移到資料庫以便於管理。",
|
||||
migrateToDatabase: "遷移到資料庫",
|
||||
migrating: "遷移中...",
|
||||
migrationSuccess: "{count} 個 API 金鑰遷移成功",
|
||||
migrationErrors: "{count} 個金鑰遷移失敗",
|
||||
migrationNothingToMigrate: "所有金鑰已在資料庫中",
|
||||
serviceType: "服務類型",
|
||||
serviceLlm: "語言模型 (LLM)",
|
||||
serviceEmbedding: "嵌入",
|
||||
serviceStt: "語音轉文字 (STT)",
|
||||
serviceTts: "文字轉語音 (TTS)",
|
||||
serviceEndpoints: "服務端點(選填)",
|
||||
azureEndpointsHint: "如有需要,為每種服務類型設定不同的端點。",
|
||||
endpointPlaceholder: "https://your-resource.openai.azure.com/",
|
||||
openaiCompatibleHint: "設定 OpenAI 相容的 API 端點。每種服務類型可以有自己的設定。",
|
||||
baseUrlPlaceholder: "https://api.example.com/v1",
|
||||
learnMore: "瞭解如何設定 API 金鑰 →",
|
||||
testConnection: "測試連線",
|
||||
testing: "測試中...",
|
||||
testSuccess: "連線成功",
|
||||
testFailed: "連線測試失敗",
|
||||
syncModels: "同步模型",
|
||||
syncing: "同步中...",
|
||||
syncSuccess: "發現 {discovered} 個模型,新增 {new} 個",
|
||||
syncNoNew: "發現 {count} 個模型,全部已註冊",
|
||||
syncFailed: "同步模型失敗",
|
||||
syncAllModels: "同步所有供應商",
|
||||
syncAllSuccess: "在所有供應商中發現 {discovered} 個模型,新增 {new} 個",
|
||||
modelsConfigured: "{count} 個模型",
|
||||
noModelsConfigured: "無模型",
|
||||
viewModels: "查看模型",
|
||||
supportedTypes: "支援的類型",
|
||||
typeLanguage: "語言",
|
||||
typeEmbedding: "嵌入",
|
||||
typeTts: "TTS",
|
||||
typeStt: "STT",
|
||||
apiEndpoint: "API 端點",
|
||||
getApiKey: "取得 API 金鑰",
|
||||
vertexProject: "GCP 專案 ID",
|
||||
vertexLocation: "區域",
|
||||
vertexCredentials: "服務帳戶 JSON 路徑",
|
||||
vertexCredentialsHint: "容器內 Google Cloud 服務帳戶 JSON 檔案的路徑。",
|
||||
|
||||
// Multi-config translations
|
||||
configsCount: "{count} 個設定",
|
||||
configuredMultiple: "已設定",
|
||||
addConfig: "新增設定",
|
||||
editConfig: "編輯設定",
|
||||
deleteConfig: "刪除設定",
|
||||
setAsDefault: "設為預設",
|
||||
defaultBadge: "預設",
|
||||
defaultDescription: "此供應商的預設設定",
|
||||
configName: "設定名稱",
|
||||
configNameHint: "此設定的描述性名稱(例如:'生產環境'、'開發環境')",
|
||||
baseUrl: "基礎 URL",
|
||||
baseUrlHint: "預設:{url}",
|
||||
baseUrlOverrideHint: "僅在需要覆蓋提供商預設 API 端點時更改此項。",
|
||||
ollamaApiKeyHint: "僅 Ollama Cloud 需要 API 金鑰。本地 Ollama 請留空。",
|
||||
noConfigs: "暫無設定",
|
||||
noConfigsHint: "新增設定以開始使用此供應商",
|
||||
deleteConfigConfirm: "確定要刪除 '{name}' 嗎?此操作無法撤銷。",
|
||||
setDefaultConfirm: "將 '{name}' 設為預設設定?",
|
||||
configSaveSuccess: "設定儲存成功",
|
||||
configUpdateSuccess: "設定更新成功",
|
||||
configDeleteSuccess: "設定刪除成功",
|
||||
configSetDefaultSuccess: "預設設定已更新",
|
||||
apiKeyHint: "輸入此設定的 API 金鑰",
|
||||
apiKeyEditHint: "留空以保留現有 API 金鑰",
|
||||
},
|
||||
setupBanner: {
|
||||
encryptionRequired: "未設定加密金鑰",
|
||||
encryptionRequiredDescription: "請設定 OPEN_NOTEBOOK_ENCRYPTION_KEY 環境變數以啟用安全憑據儲存。",
|
||||
migrationAvailable: "API 金鑰遷移可用",
|
||||
migrationDescription: "{count} 個供應商的 API 金鑰透過環境變數設定。將它們遷移到資料庫以便於管理。",
|
||||
goToSettings: "前往設定",
|
||||
viewDocs: "查看文件",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ export interface Model {
|
|||
name: string
|
||||
provider: string
|
||||
type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
||||
credential?: string | null
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
|
@ -11,6 +12,7 @@ export interface CreateModelRequest {
|
|||
name: string
|
||||
provider: string
|
||||
type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
||||
credential?: string
|
||||
}
|
||||
|
||||
export interface ModelDefaults {
|
||||
|
|
@ -27,4 +29,43 @@ export interface ProviderAvailability {
|
|||
available: string[]
|
||||
unavailable: string[]
|
||||
supported_types: Record<string, string[]>
|
||||
}
|
||||
|
||||
// Model Discovery Types
|
||||
export interface DiscoveredModel {
|
||||
name: string
|
||||
provider: string
|
||||
model_type: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ProviderSyncResult {
|
||||
provider: string
|
||||
discovered: number
|
||||
new: number
|
||||
existing: number
|
||||
}
|
||||
|
||||
export interface AllProvidersSyncResult {
|
||||
results: Record<string, ProviderSyncResult>
|
||||
total_discovered: number
|
||||
total_new: number
|
||||
}
|
||||
|
||||
export interface ProviderModelCount {
|
||||
provider: string
|
||||
counts: Record<string, number>
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AutoAssignResult {
|
||||
assigned: Record<string, string> // slot_name -> model_id
|
||||
skipped: string[] // slots already assigned
|
||||
missing: string[] // slots with no available models
|
||||
}
|
||||
|
||||
export interface ModelTestResult {
|
||||
success: boolean
|
||||
message: string
|
||||
details?: string
|
||||
}
|
||||
|
|
@ -1,242 +1,221 @@
|
|||
# Open Notebook Core Backend
|
||||
# Open Notebook - Root CLAUDE.md
|
||||
|
||||
The `open_notebook` module is the heart of the system: a multi-layer backend orchestrating AI-powered research workflows. It bridges domain models, asynchronous database operations, LangGraph-based content processing, and multi-provider AI model management.
|
||||
This file provides architectural guidance for contributors working on Open Notebook at the project level.
|
||||
|
||||
## Purpose
|
||||
## Project Overview
|
||||
|
||||
Encapsulates the entire backend architecture:
|
||||
1. **Data layer**: SurrealDB persistence with async CRUD and migrations
|
||||
2. **Domain layer**: Research models (Notebook, Source, Note, etc.) with embedded relationships
|
||||
3. **Workflow layer**: LangGraph state machines for content ingestion, chat, and transformations
|
||||
4. **AI provisioning**: Multi-provider model management with smart fallback logic
|
||||
5. **Support services**: Context building, tokenization, and utility functions
|
||||
**Open Notebook** is an open-source, privacy-focused alternative to Google's Notebook LM. It's an AI-powered research assistant enabling users to upload multi-modal content (PDFs, audio, video, web pages), generate intelligent notes, search semantically, chat with AI models, and produce professional podcasts—all with complete control over data and choice of AI providers.
|
||||
|
||||
All components communicate through async/await patterns and use Pydantic for validation.
|
||||
**Key Values**: Privacy-first, multi-provider AI support, fully self-hosted option, open-source transparency.
|
||||
|
||||
## Architecture Overview
|
||||
---
|
||||
|
||||
## Three-Tier Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ API / Streamlit UI │
|
||||
└──────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌──────────────────┴──────────────────┐
|
||||
│ │
|
||||
┌───▼────────────────────┐ ┌──────────▼────────────────┐
|
||||
│ Graphs (LangGraph) │ │ Domain Models (Data) │
|
||||
│ - source.py (ingestion) │ │ - Notebook, Source, Note │
|
||||
│ - chat.py │ │ - ChatSession, Asset │
|
||||
│ - ask.py (search) │ │ - SourceInsight, Embedding│
|
||||
│ - transformation.py │ │ - Transformation, Settings│
|
||||
└───┬────────────────────┘ │ - EpisodeProfile, Podcast │
|
||||
│ └──────────┬─────────────────┘
|
||||
│ │
|
||||
└───────────────────┬───────────────┘
|
||||
│
|
||||
┌───────────────────┴────────────────────┐
|
||||
│ │
|
||||
┌───▼─────────────────┐ ┌──────────────▼──────┐
|
||||
│ AI Module (Models) │ │ Utils (Helpers) │
|
||||
│ - ModelManager │ │ - ContextBuilder │
|
||||
│ - DefaultModels │ │ - TokenUtils │
|
||||
│ - provision_langchain│ │ - TextUtils │
|
||||
│ - Multi-provider AI │ │ - VersionUtils │
|
||||
└───┬─────────────────┘ └──────────┬──────────┘
|
||||
│ │
|
||||
└───────────────────┬───────────────┘
|
||||
│
|
||||
┌──────────────▼────────────────┐
|
||||
│ Database (SurrealDB) │
|
||||
│ - repository.py (CRUD ops) │
|
||||
│ - async_migrate.py (schema) │
|
||||
│ - Configuration │
|
||||
└────────────────────────────────┘
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React/Next.js) │
|
||||
│ frontend/ @ port 3000 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ - Notebooks, sources, notes, chat, podcasts, search UI │
|
||||
│ - Zustand state management, TanStack Query (React Query)│
|
||||
│ - Shadcn/ui component library with Tailwind CSS │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│ HTTP REST
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ API (FastAPI) │
|
||||
│ api/ @ port 5055 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ - REST endpoints for notebooks, sources, notes, chat │
|
||||
│ - LangGraph workflow orchestration │
|
||||
│ - Job queue for async operations (podcasts) │
|
||||
│ - Multi-provider AI provisioning via Esperanto │
|
||||
└────────────────────────┬────────────────────────────────┘
|
||||
│ SurrealQL
|
||||
┌────────────────────────▼────────────────────────────────┐
|
||||
│ Database (SurrealDB) │
|
||||
│ Graph database @ port 8000 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ - Records: Notebook, Source, Note, ChatSession, Credential│
|
||||
│ - Relationships: source-to-notebook, note-to-source │
|
||||
│ - Vector embeddings for semantic search │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Catalog
|
||||
---
|
||||
|
||||
### Core Layers
|
||||
## Useful sources
|
||||
|
||||
**See dedicated CLAUDE.md files for detailed patterns and usage:**
|
||||
User documentation is at @docs/
|
||||
|
||||
- **`database/`**: Async repository pattern (repo_query, repo_create, repo_upsert), connection pooling, and automatic schema migrations on API startup. See `database/CLAUDE.md`.
|
||||
## Tech Stack
|
||||
|
||||
- **`domain/`**: Core data models using Pydantic with SurrealDB persistence. Two base classes: `ObjectModel` (mutable records with auto-increment IDs and embedding) and `RecordModel` (singleton configuration). Includes search functions (text_search, vector_search). See `domain/CLAUDE.md`.
|
||||
### Frontend (`frontend/`)
|
||||
- **Framework**: Next.js 16 (React 19)
|
||||
- **Language**: TypeScript
|
||||
- **State Management**: Zustand
|
||||
- **Data Fetching**: TanStack Query (React Query)
|
||||
- **Styling**: Tailwind CSS + Shadcn/ui
|
||||
- **Build Tool**: Webpack (via Next.js)
|
||||
- **i18n compatible**: All front-end changes must also consider the translation keys
|
||||
|
||||
- **`graphs/`**: LangGraph state machines for async workflows. Content ingestion (source.py), conversational agents (chat.py), search synthesis (ask.py), and transformations. Uses provision_langchain_model() for smart model selection with token-aware fallback. See `graphs/CLAUDE.md`.
|
||||
### API Backend (`api/` + `open_notebook/`)
|
||||
- **Framework**: FastAPI 0.104+
|
||||
- **Language**: Python 3.11+
|
||||
- **Workflows**: LangGraph state machines
|
||||
- **Database**: SurrealDB async driver
|
||||
- **AI Providers**: Esperanto library (8+ providers: OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI)
|
||||
- **Job Queue**: Surreal-Commands for async jobs (podcasts)
|
||||
- **Logging**: Loguru
|
||||
- **Validation**: Pydantic v2
|
||||
- **Testing**: Pytest
|
||||
|
||||
- **`ai/`**: Centralized AI model lifecycle via Esperanto library. ModelManager factory with intelligent fallback (large context detection, type-specific defaults, config override). Supports 8+ providers (OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI). See `ai/CLAUDE.md`.
|
||||
### Database
|
||||
- **SurrealDB**: Graph database with built-in embedding storage and vector search
|
||||
- **Schema Migrations**: Automatic on API startup via AsyncMigrationManager
|
||||
|
||||
- **`utils/`**: Cross-cutting utilities: ContextBuilder (flexible context assembly from sources/notes/insights with token budgeting), TextUtils (truncation, cleaning), TokenUtils (GPT token counting), VersionUtils (schema compatibility). See `utils/CLAUDE.md`.
|
||||
### Additional Services
|
||||
- **Content Processing**: content-core library (file/URL extraction)
|
||||
- **Prompts**: AI-Prompter with Jinja2 templating
|
||||
- **Podcast Generation**: podcast-creator library
|
||||
- **Embeddings**: Multi-provider via Esperanto
|
||||
|
||||
- **`podcasts/`**: Podcast generation models: SpeakerProfile (TTS voice config), EpisodeProfile (generation settings), PodcastEpisode (job tracking via surreal-commands). See `podcasts/CLAUDE.md`.
|
||||
---
|
||||
|
||||
### Configuration & Exceptions
|
||||
## Architecture Highlights
|
||||
|
||||
- **`config.py`**: Paths for data folder, uploads, LangGraph checkpoints, and tiktoken cache. Auto-creates directories.
|
||||
- **`exceptions.py`**: Hierarchy of OpenNotebookError subclasses for database, file, network, authentication, and rate-limit failures.
|
||||
### 1. Async-First Design
|
||||
- All database queries, graph invocations, and API calls are async (await)
|
||||
- SurrealDB async driver with connection pooling
|
||||
- FastAPI handles concurrent requests efficiently
|
||||
|
||||
## Data Flow: Content Ingestion
|
||||
### 2. LangGraph Workflows
|
||||
- **source.py**: Content ingestion (extract → embed → save)
|
||||
- **chat.py**: Conversational agent with message history
|
||||
- **ask.py**: Search + synthesis (retrieve relevant sources → LLM)
|
||||
- **transformation.py**: Custom transformations on sources
|
||||
- All use `provision_langchain_model()` for smart model selection
|
||||
|
||||
```
|
||||
User uploads file/URL
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ source.py (LangGraph state machine) │
|
||||
├─────────────────────────────────────┤
|
||||
│ 1. content_process() │
|
||||
│ - extract_content() from file/URL│
|
||||
│ - Use ContentSettings defaults │
|
||||
│ - speech_to_text model from DB │
|
||||
│ │
|
||||
│ 2. save_source() │
|
||||
│ - Update Source with full_text │
|
||||
│ - Preserve title if empty │
|
||||
│ │
|
||||
│ 3. trigger_transformations() │
|
||||
│ - Parallel fan-out to each TXN │
|
||||
└────────────────┬────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ transformation.py (parallel)
|
||||
│ - Apply prompt to source text
|
||||
│ - Generate insights
|
||||
│ - Auto-embed results
|
||||
└──────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────┐
|
||||
│ Database Storage │
|
||||
│ - Source.full_text │
|
||||
│ - SourceInsight │
|
||||
│ - Embeddings │
|
||||
│ - (async job) │
|
||||
└────────────────────┘
|
||||
```
|
||||
### 3. Multi-Provider AI
|
||||
- **Esperanto library**: Unified interface to 8+ AI providers
|
||||
- **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
|
||||
|
||||
**Fire-and-forget embeddings**: Source.vectorize() returns command_id without awaiting; embedding happens asynchronously via surreal-commands job system.
|
||||
### 4. Database Schema
|
||||
- **Automatic migrations**: AsyncMigrationManager runs on API startup
|
||||
- **SurrealDB graph model**: Records with relationships and embeddings
|
||||
- **Vector search**: Built-in semantic search across all content
|
||||
- **Transactions**: Repo functions handle ACID operations
|
||||
|
||||
## Data Flow: Chat & Search
|
||||
### 5. Authentication
|
||||
- **Current**: Simple password middleware (insecure, dev-only)
|
||||
- **Production**: Replace with OAuth/JWT (see CONFIGURATION.md)
|
||||
|
||||
```
|
||||
User message in chat
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ ContextBuilder │
|
||||
│ - Select sources/notes │
|
||||
│ - Token budget limiting │
|
||||
│ - Priority weighting │
|
||||
└──────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────┐
|
||||
│ chat.py or ask.py (LangGraph) │
|
||||
│ - Load context from above │
|
||||
│ - provision_langchain_model() │
|
||||
│ * Auto-upgrade for large text │
|
||||
│ * Apply model_id override │
|
||||
│ - Call LLM with context │
|
||||
│ - Store message in SqliteSaver │
|
||||
└──────────┬───────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ LLM Response │
|
||||
│ (persisted) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Key Patterns Across Layers
|
||||
|
||||
### Async/Await Everywhere
|
||||
All database operations, model provisioning, and graph execution are async. Mix with sync code only via `asyncio.run()` or LangGraph's async bridges (see graphs/CLAUDE.md for workarounds).
|
||||
|
||||
### Type-Driven Dispatch
|
||||
Model types (language, embedding, speech_to_text, text_to_speech) drive factory logic in ModelManager. Domain model IDs encode their type: `notebook:uuid`, `source:uuid`, `note:uuid`.
|
||||
|
||||
### Smart Fallback Logic
|
||||
`provision_langchain_model()` auto-detects large contexts (105K+ tokens) and upgrades to dedicated large_context_model. Falls back to default_chat_model if specific type not found.
|
||||
|
||||
### Fire-and-Forget Jobs
|
||||
Time-consuming operations (embedding, podcast generation) return command_id immediately. Caller polls surreal-commands for status; no blocking.
|
||||
|
||||
### Fire-and-Forget Embedding
|
||||
Domain models submit embedding commands after save via `submit_command()` (non-blocking). Note.save() submits `embed_note`, Source.add_insight() submits `embed_insight`, Source.vectorize() submits `embed_source`. Search functions (text_search, vector_search) use embeddings for semantic matching.
|
||||
|
||||
### Relationship Management
|
||||
SurrealDB graph edges link entities: Notebook→Source (has), Source→Note (artifact), Note→Source (refers_to). See `relate()` in domain/base.py.
|
||||
|
||||
## Integration Points
|
||||
|
||||
**API startup** (`api/main.py`):
|
||||
- AsyncMigrationManager.run_migration_up() on lifespan startup
|
||||
- Ensures schema is current before handling requests
|
||||
|
||||
**Streamlit UI** (`pages/stream_app/`):
|
||||
- Calls domain models directly to fetch/create notebooks, sources, notes
|
||||
- Invokes graphs (chat, source, ask) via async wrapper
|
||||
- Relies on API for migrations (deprecated check in UI)
|
||||
|
||||
**Background Jobs** (`surreal_commands`):
|
||||
- Source.vectorize() submits async embedding job
|
||||
- PodcastEpisode.get_job_status() polls job queue
|
||||
- Decouples long-running operations from request flow
|
||||
---
|
||||
|
||||
## Important Quirks & Gotchas
|
||||
|
||||
1. **Token counting rough estimate**: Uses cl100k_base encoding; may differ 5-10% from actual model
|
||||
2. **Large context threshold hard-coded**: 105,000 token limit for large_context_model upgrade (not configurable)
|
||||
3. **Async loop gymnastics in graphs**: ThreadPoolExecutor workaround for LangGraph sync nodes calling async functions (fragile)
|
||||
4. **DefaultModels always fresh**: get_instance() bypasses singleton cache to pick up live config changes
|
||||
5. **Polymorphic model.get()**: Resolves subclass from ID prefix; fails silently if subclass not imported
|
||||
6. **RecordID string inconsistency**: repo_update() accepts both "table:id" format and full RecordID
|
||||
7. **Snapshot profiles**: podcast profiles stored as dicts, so config updates don't affect past episodes
|
||||
8. **No connection pooling**: Each repo_* creates new connection (adequate for HTTP but inefficient for bulk)
|
||||
9. **Circular import guard**: utils imports domain; domain must not import utils (breaks on import)
|
||||
10. **SqliteSaver shared location**: LangGraph checkpoints from LANGGRAPH_CHECKPOINT_FILE env var; all graphs use same file
|
||||
### API Startup
|
||||
- **Migrations run automatically** on startup; check logs for errors
|
||||
- **Must start API before UI**: UI depends on API for all data
|
||||
- **SurrealDB must be running**: API fails without database connection
|
||||
|
||||
## How to Add New Feature
|
||||
### Frontend-Backend Communication
|
||||
- **Base API URL**: Configured in `.env.local` (default: http://localhost:5055)
|
||||
- **CORS enabled**: Configured in `api/main.py` (allow all origins in dev)
|
||||
- **Rate limiting**: Not built-in; add at proxy layer for production
|
||||
|
||||
**New data model**:
|
||||
1. Create class inheriting from `ObjectModel` with `table_name` ClassVar
|
||||
2. Define Pydantic fields and validators
|
||||
3. Override `save()` to submit embedding command if searchable (use `submit_command("embed_*", id)`)
|
||||
4. Add custom methods for domain logic (get_X, add_to_Y)
|
||||
5. Register in domain/__init__.py exports
|
||||
### LangGraph Workflows
|
||||
- **Blocking operations**: Chat/podcast workflows may take minutes; no timeout
|
||||
- **State persistence**: Uses SQLite checkpoint storage in `/data/sqlite-db/`
|
||||
- **Model fallback**: If primary model fails, falls back to cheaper/smaller model
|
||||
|
||||
**New workflow**:
|
||||
1. Create state machine in graphs/WORKFLOW.py using StateGraph
|
||||
2. Import domain models and provision_langchain_model()
|
||||
3. Define nodes as async functions taking State, returning dict
|
||||
4. Compile with graph.compile()
|
||||
5. Invoke from API endpoint or Streamlit page
|
||||
### Podcast Generation
|
||||
- **Async job queue**: `podcast_service.py` submits jobs but doesn't wait
|
||||
- **Track status**: Use `/commands/{command_id}` endpoint to poll status
|
||||
- **TTS failures**: Fall back to silent audio if speech synthesis fails
|
||||
|
||||
**New AI model type**:
|
||||
1. Add type string to Model class
|
||||
2. Add AIFactory.create_* method in Esperanto
|
||||
3. Handle in ModelManager.get_model()
|
||||
4. Add DefaultModels field + getter
|
||||
### Content Processing
|
||||
- **File extraction**: Uses content-core library; supports 50+ file types
|
||||
- **URL handling**: Extracts text + metadata from web pages
|
||||
- **Large files**: Content processing is sync; may block API briefly
|
||||
|
||||
## Key Dependencies
|
||||
---
|
||||
|
||||
- **surrealdb**: AsyncSurreal client, RecordID type
|
||||
- **pydantic**: Validation, field_validator
|
||||
- **langgraph**: StateGraph, Send, SqliteSaver, async/sync bridging
|
||||
- **langchain_core**: Messages, OutputParser, RunnableConfig
|
||||
- **esperanto**: Multi-provider AI model abstraction (OpenAI, Anthropic, Google, Groq, Ollama, etc.)
|
||||
- **content-core**: File/URL content extraction
|
||||
- **ai_prompter**: Jinja2 template rendering for prompts
|
||||
- **surreal_commands**: Async job queue for embeddings, podcast generation
|
||||
- **loguru**: Structured logging throughout
|
||||
- **tiktoken**: GPT token encoding for context window estimation
|
||||
## Component References
|
||||
|
||||
## Codebase Statistics
|
||||
See dedicated CLAUDE.md files for detailed guidance:
|
||||
|
||||
- **[frontend/CLAUDE.md](../frontend/CLAUDE.md)**: React/Next.js architecture, state management, API integration
|
||||
- **[api/CLAUDE.md](../api/CLAUDE.md)**: FastAPI structure, service pattern, endpoint development
|
||||
- **[domain/CLAUDE.md](domain/CLAUDE.md)**: Data models, repository pattern, search functions
|
||||
- **[ai/CLAUDE.md](ai/CLAUDE.md)**: ModelManager, AI provider integration, Esperanto usage
|
||||
- **[graphs/CLAUDE.md](graphs/CLAUDE.md)**: LangGraph workflow design, state machines
|
||||
- **[database/CLAUDE.md](database/CLAUDE.md)**: SurrealDB operations, migrations, async patterns
|
||||
|
||||
---
|
||||
|
||||
## Documentation Map
|
||||
|
||||
- **[README.md](../README.md)**: Project overview, features, quick start
|
||||
- **[docs/index.md](../docs/index.md)**: Complete user & deployment documentation
|
||||
- **[CONFIGURATION.md](../CONFIGURATION.md)**: Environment variables, model configuration
|
||||
- **[CONTRIBUTING.md](../CONTRIBUTING.md)**: Contribution guidelines
|
||||
- **[MAINTAINER_GUIDE.md](../MAINTAINER_GUIDE.md)**: Release & maintenance procedures
|
||||
|
||||
---
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit tests**: `tests/test_domain.py`, `test_models_api.py`
|
||||
- **Graph tests**: `tests/test_graphs.py` (workflow integration)
|
||||
- **Utils tests**: `tests/test_utils.py`, `tests/test_chunking.py`, `tests/test_embedding.py`
|
||||
- **Run all**: `uv run pytest tests/`
|
||||
- **Coverage**: Check with `pytest --cov`
|
||||
|
||||
---
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a New API Endpoint
|
||||
1. Create router in `api/routers/feature.py`
|
||||
2. Create service in `api/feature_service.py`
|
||||
3. Define schemas in `api/models.py`
|
||||
4. Register router in `api/main.py`
|
||||
5. Test via http://localhost:5055/docs
|
||||
|
||||
### Add a New LangGraph Workflow
|
||||
1. Create `open_notebook/graphs/workflow_name.py`
|
||||
2. Define StateDict and node functions
|
||||
3. Build graph with `.add_node()` / `.add_edge()`
|
||||
4. Invoke in service: `graph.ainvoke({"input": ...}, config={"..."})`
|
||||
5. Test with sample data in `tests/`
|
||||
|
||||
### Add Database Migration
|
||||
1. Create `migrations/XXX_description.surql`
|
||||
2. Write SurrealQL schema changes
|
||||
3. Create `migrations/XXX_description_down.surql` (optional rollback)
|
||||
4. API auto-detects on startup; migration runs if newer than recorded version
|
||||
|
||||
### Deploy to Production
|
||||
1. Review [CONFIGURATION.md](CONFIGURATION.md) for security settings
|
||||
2. Use `make docker-release` for multi-platform image
|
||||
3. Push to Docker Hub / GitHub Container Registry
|
||||
4. Deploy `docker compose --profile multi up`
|
||||
5. Verify migrations via API logs
|
||||
|
||||
---
|
||||
|
||||
## Support & Community
|
||||
|
||||
- **Documentation**: https://open-notebook.ai
|
||||
- **Discord**: https://discord.gg/37XJPXfz2w
|
||||
- **Issues**: https://github.com/lfnovo/open-notebook/issues
|
||||
- **License**: MIT (see LICENSE)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 2026 | **Project Version**: 1.2.4+
|
||||
|
||||
- **Modules**: 6 core layers + support services
|
||||
- **Async operations**: Database, AI provisioning, graph execution, embedding, job tracking
|
||||
- **Supported AI providers**: 8+ (OpenAI, Anthropic, Google, Groq, Ollama, Mistral, DeepSeek, xAI, OpenRouter)
|
||||
- **Domain models**: Notebook, Source, Note, SourceInsight, SourceEmbedding, ChatSession, Asset, Transformation, ContentSettings, EpisodeProfile, SpeakerProfile, PodcastEpisode
|
||||
- **Graph workflows**: 6 (source, chat, source_chat, ask, transformation, prompt)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,10 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo
|
|||
### models.py
|
||||
|
||||
#### Model (ObjectModel)
|
||||
- Database record: name, provider, type (language/embedding/speech_to_text/text_to_speech)
|
||||
- Database record: name, provider, type (language/embedding/speech_to_text/text_to_speech), credential (optional link to Credential record)
|
||||
- `get_models_by_type()`: Async query to fetch all models of a specific type
|
||||
- `get_credential_obj()`: Fetches linked Credential object (if credential field set)
|
||||
- `get_by_credential(credential_id)`: Class method to find all models linked to a credential
|
||||
- Stores provider-model pairs for AI factory instantiation
|
||||
|
||||
#### DefaultModels (RecordModel)
|
||||
|
|
@ -31,7 +33,7 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo
|
|||
|
||||
#### ModelManager
|
||||
- Stateless factory for instantiating AI models
|
||||
- `get_model(model_id)`: Retrieves Model by ID, creates via AIFactory.create_* based on type
|
||||
- `get_model(model_id)`: Retrieves Model by ID; if model has linked credential, uses `credential.to_esperanto_config()` for provider config; otherwise falls back to env var provisioning via `key_provider`
|
||||
- `get_defaults()`: Fetches DefaultModels configuration
|
||||
- `get_default_model(model_type)`: Smart lookup (e.g., "chat" → default_chat_model, "transformation" → default_transformation_model with fallback to chat)
|
||||
- `get_speech_to_text()`, `get_text_to_speech()`, `get_embedding_model()`: Type-specific convenience methods with assertions
|
||||
|
|
@ -48,6 +50,24 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo
|
|||
- Returns LangChain-compatible model via `.to_langchain()`
|
||||
- Logs model selection decision
|
||||
|
||||
### key_provider.py
|
||||
|
||||
#### API Key Provider (Credential→Env Fallback)
|
||||
- **Purpose**: Provides API keys from database first, falls back to environment variables
|
||||
- **Pattern**: Before Esperanto creates a model, keys are loaded from `Credential` records and set as environment variables
|
||||
- **Integration point**: Called by `ModelManager.get_model()` as fallback when model has no linked credential
|
||||
|
||||
#### Key Functions
|
||||
- `get_api_key(provider)`: Get single API key (DB first, then env var)
|
||||
- `provision_provider_keys(provider)`: Set env vars from DB config for a provider
|
||||
- `provision_all_keys()`: Load all provider keys from DB into env vars (useful at startup)
|
||||
|
||||
#### Provider Configuration Maps
|
||||
- `PROVIDER_CONFIG`: Simple providers (openai, anthropic, google, groq, etc.)
|
||||
- `VERTEX_CONFIG`: Google Vertex AI (project, location, credentials)
|
||||
- `AZURE_CONFIG`: Azure OpenAI (api_key, endpoint, api_version, mode-specific endpoints)
|
||||
- `OPENAI_COMPATIBLE_CONFIG`: Generic OpenAI-compatible (generic + mode-specific for LLM/EMBEDDING/STT/TTS)
|
||||
|
||||
## Common Patterns
|
||||
|
||||
- **Type dispatch**: Model.type field drives factory logic (4 model types)
|
||||
|
|
@ -56,12 +76,14 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo
|
|||
- **Config override**: provision_langchain_model() accepts kwargs passed to AIFactory.create_* methods
|
||||
- **Token-based selection**: provision_langchain_model() detects large contexts and upgrades model automatically
|
||||
- **Type assertions**: get_speech_to_text(), get_embedding_model() assert returned type (safety check)
|
||||
- **Credential→Env fallback**: If model has linked credential, config from `credential.to_esperanto_config()` is used directly; otherwise keys checked in database via key_provider, then environment variables; enables UI-based key management while maintaining backward compatibility
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- `esperanto`: AIFactory.create_language(), create_embedding(), create_speech_to_text(), create_text_to_speech()
|
||||
- `open_notebook.database.repository`: repo_query, ensure_record_id
|
||||
- `open_notebook.domain.base`: ObjectModel, RecordModel base classes
|
||||
- `open_notebook.domain.credential`: Credential for database-stored API keys
|
||||
- `open_notebook.utils`: token_count() for context size detection
|
||||
- `loguru`: Logging for model selection decisions
|
||||
|
||||
|
|
@ -75,6 +97,7 @@ All models use Esperanto library as provider abstraction (OpenAI, Anthropic, Goo
|
|||
- **Esperanto caching**: Actual model instances cached by Esperanto (not by ModelManager); ModelManager stateless
|
||||
- **Fallback chain specificity**: "transformation" type falls back to default_chat_model if not explicitly set (convention-based)
|
||||
- **kwargs passed through**: provision_langchain_model() passes kwargs to AIFactory but doesn't validate what's accepted
|
||||
- **Key provider sets env vars**: `provision_provider_keys()` modifies `os.environ` to inject DB-stored keys (from `Credential` records); Esperanto reads from env vars (only used as fallback when model has no linked credential)
|
||||
|
||||
## How to Extend
|
||||
|
||||
|
|
@ -107,3 +130,202 @@ langchain_model = await provision_langchain_model(
|
|||
temperature=0.7
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Connection Testing (connection_tester.py)
|
||||
|
||||
### Purpose
|
||||
|
||||
Provides functionality to test if a provider's API key is valid by making minimal API calls. Used by the API Configuration UI to validate user-entered credentials before saving.
|
||||
|
||||
### test_provider_connection()
|
||||
|
||||
Main entry point for testing provider connectivity.
|
||||
|
||||
```python
|
||||
async def test_provider_connection(
|
||||
provider: str, model_type: str = "language",
|
||||
config_id: Optional[str] = None
|
||||
) -> Tuple[bool, str]
|
||||
```
|
||||
|
||||
**Returns**: `(success: bool, message: str)` - Success status and human-readable message.
|
||||
|
||||
**Flow**:
|
||||
1. If `config_id` provided: Loads credential via `Credential.get(config_id)`, uses `credential.to_esperanto_config()` for provider config
|
||||
2. Looks up test model from `TEST_MODELS` dict
|
||||
3. For URL-based providers (ollama, openai_compatible): Tests server connectivity
|
||||
4. For Azure: Tests `/openai/models` endpoint with api_version
|
||||
5. For API-based providers: Creates minimal model via Esperanto and makes test call
|
||||
6. Returns user-friendly error messages for common failures
|
||||
|
||||
### test_individual_model()
|
||||
|
||||
Tests a specific Model instance by loading its linked credential (if any) and making a minimal API call.
|
||||
|
||||
### TEST_MODELS Configuration
|
||||
|
||||
Maps each provider to `(model_name, model_type)` for testing:
|
||||
|
||||
```python
|
||||
TEST_MODELS = {
|
||||
"openai": ("gpt-3.5-turbo", "language"),
|
||||
"anthropic": ("claude-3-haiku-20240307", "language"),
|
||||
"google": ("gemini-1.5-flash", "language"),
|
||||
"groq": ("llama-3.1-8b-instant", "language"),
|
||||
"voyage": ("voyage-3-lite", "embedding"),
|
||||
"elevenlabs": ("eleven_multilingual_v2", "text_to_speech"),
|
||||
"ollama": (None, "language"), # Dynamic
|
||||
# ... more providers
|
||||
}
|
||||
```
|
||||
|
||||
### Special Provider Handlers
|
||||
|
||||
- **`_test_ollama_connection(base_url)`**: Tests Ollama server via `/api/tags` endpoint, returns model count
|
||||
- **`_test_openai_compatible_connection(base_url, api_key)`**: Tests OpenAI-compatible servers via `/models` endpoint
|
||||
- **`_get_ollama_models(base_url)`**: Fetches available models from Ollama server
|
||||
|
||||
### Error Message Normalization
|
||||
|
||||
The tester normalizes error messages for user-friendly display:
|
||||
- `401/unauthorized` -> "Invalid API key"
|
||||
- `403/forbidden` -> "API key lacks required permissions"
|
||||
- `rate limit` -> "Rate limited - but connection works" (success)
|
||||
- `model not found` -> "API key valid (test model not available)" (success)
|
||||
- Connection/timeout errors -> Helpful troubleshooting messages
|
||||
|
||||
---
|
||||
|
||||
## Key Provider (key_provider.py)
|
||||
|
||||
### Purpose
|
||||
|
||||
Unified interface for retrieving API keys with database-first, environment-fallback strategy. Enables UI-based key management while maintaining backward compatibility with `.env` files. Used as fallback when models don't have a directly linked credential.
|
||||
|
||||
### Core Functions
|
||||
|
||||
#### get_api_key(provider)
|
||||
|
||||
```python
|
||||
async def get_api_key(provider: str) -> Optional[str]
|
||||
```
|
||||
|
||||
Gets API key for a provider. Checks database (`Credential` records) first, then environment variable.
|
||||
|
||||
**Fallback Chain**:
|
||||
1. Query `Credential` records from database for the given provider
|
||||
2. Get api_key from default credential
|
||||
3. Handle `SecretStr` (call `.get_secret_value()`) vs regular strings
|
||||
4. If DB value exists and is non-empty, return it
|
||||
5. Otherwise, return `os.environ.get(env_var)`
|
||||
|
||||
#### provision_provider_keys(provider)
|
||||
|
||||
```python
|
||||
async def provision_provider_keys(provider: str) -> bool
|
||||
```
|
||||
|
||||
Main entry point for DB->Env fallback. Sets environment variables from database config for a provider. Called before model provisioning to ensure Esperanto can read keys from env vars.
|
||||
|
||||
**Returns**: `True` if any keys were set from database.
|
||||
|
||||
**Usage**:
|
||||
```python
|
||||
# Before creating a model, ensure DB keys are in env vars
|
||||
await provision_provider_keys("openai")
|
||||
model = AIFactory.create_language(model_name="gpt-4", provider="openai")
|
||||
```
|
||||
|
||||
#### provision_all_keys()
|
||||
|
||||
```python
|
||||
async def provision_all_keys() -> dict[str, bool]
|
||||
```
|
||||
|
||||
Provisions all providers at once. Useful at application startup.
|
||||
|
||||
### Provider Configuration Maps
|
||||
|
||||
#### PROVIDER_CONFIG (Simple Providers)
|
||||
|
||||
Single-field providers with API key only:
|
||||
|
||||
```python
|
||||
PROVIDER_CONFIG = {
|
||||
"openai": {"env_var": "OPENAI_API_KEY", "config_field": "openai_api_key"},
|
||||
"anthropic": {"env_var": "ANTHROPIC_API_KEY", "config_field": "anthropic_api_key"},
|
||||
"google": {"env_var": "GOOGLE_API_KEY", "config_field": "google_api_key"},
|
||||
"groq": {"env_var": "GROQ_API_KEY", "config_field": "groq_api_key"},
|
||||
"mistral": {"env_var": "MISTRAL_API_KEY", "config_field": "mistral_api_key"},
|
||||
"deepseek": {"env_var": "DEEPSEEK_API_KEY", "config_field": "deepseek_api_key"},
|
||||
"xai": {"env_var": "XAI_API_KEY", "config_field": "xai_api_key"},
|
||||
"openrouter": {"env_var": "OPENROUTER_API_KEY", "config_field": "openrouter_api_key"},
|
||||
"voyage": {"env_var": "VOYAGE_API_KEY", "config_field": "voyage_api_key"},
|
||||
"elevenlabs": {"env_var": "ELEVENLABS_API_KEY", "config_field": "elevenlabs_api_key"},
|
||||
"ollama": {"env_var": "OLLAMA_API_BASE", "config_field": "ollama_api_base"},
|
||||
}
|
||||
```
|
||||
|
||||
#### VERTEX_CONFIG (Google Vertex AI)
|
||||
|
||||
Multi-field configuration for Vertex AI:
|
||||
|
||||
```python
|
||||
VERTEX_CONFIG = {
|
||||
"project": {"env_var": "VERTEX_PROJECT", "config_field": "vertex_project"},
|
||||
"location": {"env_var": "VERTEX_LOCATION", "config_field": "vertex_location"},
|
||||
"credentials": {"env_var": "GOOGLE_APPLICATION_CREDENTIALS", "config_field": "google_application_credentials"},
|
||||
}
|
||||
```
|
||||
|
||||
#### AZURE_CONFIG (Azure OpenAI)
|
||||
|
||||
Generic and mode-specific endpoints for Azure:
|
||||
|
||||
```python
|
||||
AZURE_CONFIG = {
|
||||
"api_key": {"env_var": "AZURE_OPENAI_API_KEY", "config_field": "azure_openai_api_key"},
|
||||
"api_version": {"env_var": "AZURE_OPENAI_API_VERSION", "config_field": "azure_openai_api_version"},
|
||||
"endpoint": {"env_var": "AZURE_OPENAI_ENDPOINT", "config_field": "azure_openai_endpoint"},
|
||||
# Mode-specific endpoints
|
||||
"endpoint_llm": {"env_var": "AZURE_OPENAI_ENDPOINT_LLM", "config_field": "azure_openai_endpoint_llm"},
|
||||
"endpoint_embedding": {"env_var": "AZURE_OPENAI_ENDPOINT_EMBEDDING", "config_field": "azure_openai_endpoint_embedding"},
|
||||
"endpoint_stt": {"env_var": "AZURE_OPENAI_ENDPOINT_STT", "config_field": "azure_openai_endpoint_stt"},
|
||||
"endpoint_tts": {"env_var": "AZURE_OPENAI_ENDPOINT_TTS", "config_field": "azure_openai_endpoint_tts"},
|
||||
}
|
||||
```
|
||||
|
||||
#### OPENAI_COMPATIBLE_CONFIG
|
||||
|
||||
Generic and mode-specific configuration for OpenAI-compatible providers:
|
||||
|
||||
```python
|
||||
OPENAI_COMPATIBLE_CONFIG = {
|
||||
# Generic
|
||||
"api_key": {"env_var": "OPENAI_COMPATIBLE_API_KEY", "config_field": "openai_compatible_api_key"},
|
||||
"base_url": {"env_var": "OPENAI_COMPATIBLE_BASE_URL", "config_field": "openai_compatible_base_url"},
|
||||
# Mode-specific: LLM, Embedding, STT, TTS
|
||||
"api_key_llm": {"env_var": "OPENAI_COMPATIBLE_API_KEY_LLM", "config_field": "openai_compatible_api_key_llm"},
|
||||
"base_url_llm": {"env_var": "OPENAI_COMPATIBLE_BASE_URL_LLM", "config_field": "openai_compatible_base_url_llm"},
|
||||
# ... similar for embedding, stt, tts
|
||||
}
|
||||
```
|
||||
|
||||
### Internal Helper Functions
|
||||
|
||||
- **`_provision_simple_provider(provider)`**: Sets single env var for simple providers
|
||||
- **`_provision_vertex()`**: Sets all Vertex AI env vars
|
||||
- **`_provision_azure()`**: Sets all Azure OpenAI env vars (handles SecretStr)
|
||||
- **`_provision_openai_compatible()`**: Sets all OpenAI-compatible env vars
|
||||
|
||||
### Integration with ModelManager
|
||||
|
||||
The credential system integrates with model provisioning in two ways:
|
||||
|
||||
1. **Credential-linked models** (preferred): Model has `credential` field pointing to a Credential record. `ModelManager.get_model()` calls `credential.to_esperanto_config()` and passes config directly to Esperanto's `AIFactory.create_*` methods
|
||||
2. **Env var fallback**: If model has no linked credential, `provision_provider_keys(provider)` sets env vars from DB credentials; Esperanto reads from env vars
|
||||
3. **ConnectionTester** loads Credential directly via `Credential.get(config_id)` for testing
|
||||
|
||||
The credential-linked approach is preferred as it allows multiple credentials per provider and avoids env var mutation.
|
||||
|
|
|
|||
438
open_notebook/ai/connection_tester.py
Normal file
438
open_notebook/ai/connection_tester.py
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
"""
|
||||
Connection testing for AI providers.
|
||||
|
||||
This module provides functionality to test if a provider's API key is valid
|
||||
by making minimal API calls to each provider, and to test individual model
|
||||
configurations end-to-end.
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from esperanto.factory import AIFactory
|
||||
from loguru import logger
|
||||
|
||||
from open_notebook.domain.credential import Credential
|
||||
|
||||
# Test models for each provider - uses minimal/cheapest models for testing
|
||||
# Format: (model_name, model_type)
|
||||
TEST_MODELS = {
|
||||
"openai": ("gpt-3.5-turbo", "language"),
|
||||
"anthropic": ("claude-3-haiku-20240307", "language"),
|
||||
"google": ("gemini-2.0-flash", "language"),
|
||||
"groq": ("llama-3.1-8b-instant", "language"),
|
||||
"mistral": ("mistral-small-latest", "language"),
|
||||
"deepseek": ("deepseek-chat", "language"),
|
||||
"xai": ("grok-beta", "language"),
|
||||
"openrouter": ("openai/gpt-3.5-turbo", "language"),
|
||||
"voyage": ("voyage-3-lite", "embedding"),
|
||||
"elevenlabs": ("eleven_multilingual_v2", "text_to_speech"),
|
||||
"ollama": (None, "language"), # Dynamic - will use first available model
|
||||
# Complex providers with additional configuration
|
||||
"vertex": ("gemini-2.0-flash", "language"), # Uses Google Vertex AI
|
||||
"azure": ("gpt-35-turbo", "language"), # Azure OpenAI deployment name
|
||||
"openai_compatible": (None, "language"), # Dynamic - will use first available model
|
||||
}
|
||||
|
||||
|
||||
async def _test_azure_connection(
|
||||
endpoint: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
api_version: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Test Azure OpenAI connectivity by listing models.
|
||||
|
||||
Azure requires deployment names which vary per user, so instead of
|
||||
invoking a model, we list available models to validate credentials.
|
||||
"""
|
||||
test_endpoint = endpoint or os.environ.get("AZURE_OPENAI_ENDPOINT")
|
||||
test_api_key = api_key or os.environ.get("AZURE_OPENAI_API_KEY")
|
||||
test_api_version = api_version or os.environ.get("AZURE_OPENAI_API_VERSION", "2024-06-01")
|
||||
|
||||
if not test_endpoint:
|
||||
return False, "No Azure endpoint configured"
|
||||
if not test_api_key:
|
||||
return False, "No Azure API key configured"
|
||||
|
||||
# Strip trailing slash to avoid double-slash in URL
|
||||
test_endpoint = test_endpoint.rstrip("/")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
f"{test_endpoint}/openai/models?api-version={test_api_version}",
|
||||
headers={"api-key": test_api_key},
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = data.get("data", [])
|
||||
count = len(models)
|
||||
if count > 0:
|
||||
names = [m.get("id", "unknown") for m in models[:3]]
|
||||
name_list = ", ".join(names)
|
||||
if count > 3:
|
||||
name_list += f" (+{count - 3} more)"
|
||||
return True, f"Connected. {count} models: {name_list}"
|
||||
else:
|
||||
return True, "Connected successfully (no models found)"
|
||||
elif response.status_code == 401:
|
||||
return False, "Invalid API key"
|
||||
elif response.status_code == 403:
|
||||
return False, "API key lacks required permissions"
|
||||
else:
|
||||
return False, f"Azure returned status {response.status_code}"
|
||||
|
||||
except httpx.ConnectError:
|
||||
return False, "Cannot connect to Azure endpoint. Check the URL."
|
||||
except httpx.TimeoutException:
|
||||
return False, "Connection timed out. Check the endpoint URL."
|
||||
except Exception as e:
|
||||
return False, f"Connection error: {str(e)[:100]}"
|
||||
|
||||
|
||||
async def _test_ollama_connection(base_url: str) -> Tuple[bool, str]:
|
||||
"""Test Ollama server connectivity."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Try /api/tags endpoint (standard Ollama)
|
||||
response = await client.get(f"{base_url}/api/tags")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = data.get("models", [])
|
||||
model_count = len(models)
|
||||
|
||||
if model_count > 0:
|
||||
model_names = [m.get("name", "unknown") for m in models[:3]]
|
||||
model_list = ", ".join(model_names)
|
||||
if model_count > 3:
|
||||
model_list += f" (+{model_count - 3} more)"
|
||||
return True, f"Connected. {model_count} models available: {model_list}"
|
||||
else:
|
||||
return True, "Connected successfully (no models listed)"
|
||||
elif response.status_code == 401:
|
||||
return False, "Invalid API key"
|
||||
elif response.status_code == 403:
|
||||
return False, "API key lacks required permissions"
|
||||
else:
|
||||
return False, f"Server returned status {response.status_code}"
|
||||
|
||||
except httpx.ConnectError:
|
||||
return False, "Cannot connect to Ollama. Check if Ollama server is running."
|
||||
except httpx.TimeoutException:
|
||||
return False, "Connection timed out. Check if Ollama server is accessible."
|
||||
except Exception as e:
|
||||
return False, f"Connection error: {str(e)[:100]}"
|
||||
|
||||
|
||||
async def _test_openai_compatible_connection(base_url: str, api_key: Optional[str] = None) -> Tuple[bool, str]:
|
||||
"""Test OpenAI-compatible server connectivity."""
|
||||
try:
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Try /models endpoint (standard OpenAI-compatible)
|
||||
response = await client.get(f"{base_url}/models", headers=headers)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = data.get("data", [])
|
||||
model_count = len(models)
|
||||
|
||||
if model_count > 0:
|
||||
model_names = [m.get("id", "unknown") for m in models[:3]]
|
||||
model_list = ", ".join(model_names)
|
||||
if model_count > 3:
|
||||
model_list += f" (+{model_count - 3} more)"
|
||||
return True, f"Connected. {model_count} models available: {model_list}"
|
||||
else:
|
||||
return True, "Connected successfully (no models listed)"
|
||||
elif response.status_code == 401:
|
||||
return False, "Invalid API key"
|
||||
elif response.status_code == 403:
|
||||
return False, "API key lacks required permissions"
|
||||
else:
|
||||
return False, f"Server returned status {response.status_code}"
|
||||
|
||||
except httpx.ConnectError:
|
||||
return False, "Cannot connect to server. Check the URL is correct."
|
||||
except httpx.TimeoutException:
|
||||
return False, "Connection timed out. Check if server is accessible."
|
||||
except Exception as e:
|
||||
return False, f"Connection error: {str(e)[:100]}"
|
||||
|
||||
async def test_provider_connection(
|
||||
provider: str, model_type: str = "language", config_id: Optional[str] = None
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Test if a provider's API key is valid by making a minimal API call.
|
||||
|
||||
Args:
|
||||
provider: Provider name (openai, anthropic, etc.)
|
||||
model_type: Type of model to test (language, embedding, etc.)
|
||||
Note: This is overridden by TEST_MODELS if provider is in that dict.
|
||||
config_id: Optional specific configuration ID to test (format: configId)
|
||||
If provided, uses the configuration from ProviderConfig for this specific config.
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
try:
|
||||
# Get configuration - either specific config or default
|
||||
api_key: Optional[str] = None
|
||||
base_url: Optional[str] = None
|
||||
endpoint: Optional[str] = None
|
||||
api_version: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
|
||||
if config_id:
|
||||
# Load specific credential from database
|
||||
try:
|
||||
cred = await Credential.get(config_id)
|
||||
config = cred.to_esperanto_config()
|
||||
api_key = config.get("api_key")
|
||||
base_url = config.get("base_url")
|
||||
endpoint = config.get("endpoint")
|
||||
api_version = config.get("api_version")
|
||||
except Exception:
|
||||
return False, f"Credential not found: {config_id}"
|
||||
|
||||
# Normalize provider name (handle hyphenated aliases)
|
||||
normalized_provider = provider.replace("-", "_")
|
||||
|
||||
# Special handling for URL-based providers (no API key, just connectivity)
|
||||
if normalized_provider == "ollama":
|
||||
# Use base_url from specific config, or environment variable
|
||||
test_base_url = base_url or os.environ.get("OLLAMA_API_BASE", "http://localhost:11434")
|
||||
return await _test_ollama_connection(test_base_url)
|
||||
|
||||
if normalized_provider == "openai_compatible":
|
||||
# Use base_url from specific config, or environment variable
|
||||
test_base_url = base_url or os.environ.get("OPENAI_COMPATIBLE_BASE_URL")
|
||||
test_api_key = api_key or os.environ.get("OPENAI_COMPATIBLE_API_KEY")
|
||||
if not test_base_url:
|
||||
return False, "No base URL configured for OpenAI-compatible provider"
|
||||
return await _test_openai_compatible_connection(test_base_url, test_api_key)
|
||||
|
||||
if normalized_provider == "azure":
|
||||
return await _test_azure_connection(endpoint, api_key, api_version)
|
||||
|
||||
# Get test model for provider
|
||||
if normalized_provider not in TEST_MODELS:
|
||||
return False, f"Unknown provider: {provider}"
|
||||
|
||||
test_model, test_model_type = TEST_MODELS[normalized_provider]
|
||||
|
||||
# Use model from config if provided, otherwise use TEST_MODELS default
|
||||
model_to_use = model_name if model_name else test_model
|
||||
|
||||
# For providers with dynamic model detection
|
||||
if model_to_use is None:
|
||||
if normalized_provider == "openai_compatible":
|
||||
# OpenAI-compatible servers should already be tested via _test_openai_compatible_connection
|
||||
test_base_url = base_url or os.environ.get("OPENAI_COMPATIBLE_BASE_URL", "")
|
||||
test_api_key = api_key or os.environ.get("OPENAI_COMPATIBLE_API_KEY")
|
||||
return await _test_openai_compatible_connection(test_base_url, test_api_key)
|
||||
else:
|
||||
return False, f"No test model configured for {provider}"
|
||||
|
||||
# If we have a specific API key, set it in environment for this test
|
||||
if api_key:
|
||||
os.environ[f"{provider.upper()}_API_KEY"] = api_key
|
||||
|
||||
# Try to create the model and make a minimal call
|
||||
if test_model_type == "language":
|
||||
model = AIFactory.create_language(model_name=model_to_use, provider=provider)
|
||||
# Convert to LangChain and make a minimal call
|
||||
lc_model = model.to_langchain()
|
||||
await lc_model.ainvoke("Hi")
|
||||
return True, "Connection successful"
|
||||
|
||||
elif test_model_type == "embedding":
|
||||
model = AIFactory.create_embedding(model_name=model_to_use, provider=provider)
|
||||
# Embed a single short test string
|
||||
await model.aembed(["test"])
|
||||
return True, "Connection successful"
|
||||
|
||||
elif test_model_type == "text_to_speech":
|
||||
# For TTS, we just verify the model can be created
|
||||
# Making an actual TTS call would be more expensive
|
||||
# Most TTS providers validate the key on model creation
|
||||
AIFactory.create_text_to_speech(
|
||||
model_name=model_to_use, provider=provider
|
||||
)
|
||||
return True, "Connection successful (key format valid)"
|
||||
|
||||
else:
|
||||
return False, f"Unsupported model type for testing: {test_model_type}"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
|
||||
# Clean up common error messages for user-friendly display
|
||||
if "401" in error_msg or "unauthorized" in error_msg.lower():
|
||||
return False, "Invalid API key"
|
||||
elif "403" in error_msg or "forbidden" in error_msg.lower():
|
||||
return False, "API key lacks required permissions"
|
||||
elif "rate" in error_msg.lower() and "limit" in error_msg.lower():
|
||||
# Rate limit means the key is valid but we hit limits
|
||||
return True, "Rate limited - but connection works"
|
||||
elif "connection" in error_msg.lower() or "network" in error_msg.lower():
|
||||
return False, "Connection error - check network/endpoint"
|
||||
elif "timeout" in error_msg.lower():
|
||||
return False, "Connection timed out - check network/endpoint"
|
||||
elif "not found" in error_msg.lower() and "model" in error_msg.lower():
|
||||
# Model not found but auth worked - this is actually a success for connectivity
|
||||
return True, "API key valid (test model not available)"
|
||||
elif provider == "ollama" and "connection refused" in error_msg.lower():
|
||||
return False, "Ollama not running - check if Ollama server is started"
|
||||
else:
|
||||
logger.debug(f"Test connection error for {provider}: {e}")
|
||||
# Truncate long error messages
|
||||
truncated = error_msg[:100] + "..." if len(error_msg) > 100 else error_msg
|
||||
return False, f"Error: {truncated}"
|
||||
|
||||
|
||||
# Default voices for TTS testing per provider
|
||||
# ElevenLabs excluded: uses voice_id (not name), looked up dynamically
|
||||
DEFAULT_TEST_VOICES = {
|
||||
"openai": "alloy",
|
||||
"azure": "alloy",
|
||||
"google": "Kore",
|
||||
"vertex": "Kore",
|
||||
"openai_compatible": "alloy",
|
||||
}
|
||||
|
||||
|
||||
def _generate_test_wav() -> io.BytesIO:
|
||||
"""Generate a minimal 0.5s silence WAV file in memory (16kHz, 16-bit mono)."""
|
||||
sample_rate = 16000
|
||||
num_samples = sample_rate // 2 # 0.5 seconds
|
||||
bits_per_sample = 16
|
||||
num_channels = 1
|
||||
byte_rate = sample_rate * num_channels * bits_per_sample // 8
|
||||
block_align = num_channels * bits_per_sample // 8
|
||||
data_size = num_samples * block_align
|
||||
|
||||
buf = io.BytesIO()
|
||||
# RIFF header
|
||||
buf.write(b"RIFF")
|
||||
buf.write(struct.pack("<I", 36 + data_size))
|
||||
buf.write(b"WAVE")
|
||||
# fmt chunk
|
||||
buf.write(b"fmt ")
|
||||
buf.write(struct.pack("<I", 16)) # chunk size
|
||||
buf.write(struct.pack("<H", 1)) # PCM format
|
||||
buf.write(struct.pack("<H", num_channels))
|
||||
buf.write(struct.pack("<I", sample_rate))
|
||||
buf.write(struct.pack("<I", byte_rate))
|
||||
buf.write(struct.pack("<H", block_align))
|
||||
buf.write(struct.pack("<H", bits_per_sample))
|
||||
# data chunk
|
||||
buf.write(b"data")
|
||||
buf.write(struct.pack("<I", data_size))
|
||||
buf.write(b"\x00" * data_size) # silence
|
||||
|
||||
buf.seek(0)
|
||||
buf.name = "test.wav"
|
||||
return buf
|
||||
|
||||
|
||||
def _normalize_error_message(error_msg: str) -> Tuple[bool, str]:
|
||||
"""Normalize common error patterns into user-friendly messages."""
|
||||
lower = error_msg.lower()
|
||||
|
||||
if "401" in error_msg or "unauthorized" in lower:
|
||||
return False, "Invalid API key"
|
||||
elif "403" in error_msg or "forbidden" in lower:
|
||||
return False, "API key lacks required permissions"
|
||||
elif "rate" in lower and "limit" in lower:
|
||||
return True, "Rate limited - but connection works"
|
||||
elif "not found" in lower and "model" in lower:
|
||||
return False, "Model not found on this provider"
|
||||
elif "connection" in lower or "network" in lower:
|
||||
return False, "Connection error - check network/endpoint"
|
||||
elif "timeout" in lower:
|
||||
return False, "Connection timed out - check network/endpoint"
|
||||
|
||||
return False, error_msg
|
||||
|
||||
|
||||
async def test_individual_model(model) -> Tuple[bool, str]:
|
||||
"""
|
||||
Test a specific model configuration end-to-end by making a real API call.
|
||||
|
||||
Args:
|
||||
model: A Model instance (from open_notebook.ai.models)
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, message: str)
|
||||
"""
|
||||
from open_notebook.ai.models import ModelManager
|
||||
|
||||
try:
|
||||
manager = ModelManager()
|
||||
esp_model = await manager.get_model(model.id)
|
||||
|
||||
if esp_model is None:
|
||||
return False, "Could not create model instance"
|
||||
|
||||
if model.type == "language":
|
||||
response = await esp_model.achat_complete(
|
||||
messages=[{"role": "user", "content": "Hi!"}]
|
||||
)
|
||||
text = response.content[:100] if response.content else "(empty response)"
|
||||
return True, f"Response: {text}"
|
||||
|
||||
elif model.type == "embedding":
|
||||
result = await esp_model.aembed(["This is a test."])
|
||||
if result and len(result) > 0:
|
||||
dims = len(result[0])
|
||||
return True, f"Embedding dimensions: {dims}"
|
||||
return True, "Embedding successful"
|
||||
|
||||
elif model.type == "text_to_speech":
|
||||
# For ElevenLabs, look up first available voice (API uses voice_id, not name)
|
||||
voice = DEFAULT_TEST_VOICES.get(model.provider)
|
||||
if not voice and hasattr(esp_model, "available_voices"):
|
||||
try:
|
||||
voices = esp_model.available_voices
|
||||
if voices:
|
||||
voice = next(iter(voices.keys()))
|
||||
except Exception:
|
||||
pass
|
||||
if not voice:
|
||||
voice = "alloy" # fallback
|
||||
|
||||
result = await esp_model.agenerate_speech(
|
||||
text="Hello from Open Notebook", voice=voice
|
||||
)
|
||||
if result and hasattr(result, "content"):
|
||||
size = len(result.content)
|
||||
return True, f"Audio generated: {size} bytes"
|
||||
return True, "Speech generation successful"
|
||||
|
||||
elif model.type == "speech_to_text":
|
||||
audio_file = _generate_test_wav()
|
||||
result = await esp_model.atranscribe(
|
||||
audio_file=audio_file, language="en"
|
||||
)
|
||||
text = str(result.text) if hasattr(result, "text") else str(result)
|
||||
return True, f"Transcription: {text[:100]}"
|
||||
|
||||
else:
|
||||
return False, f"Unsupported model type: {model.type}"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
success, normalized = _normalize_error_message(error_msg)
|
||||
if success:
|
||||
return True, normalized
|
||||
logger.debug(f"Test individual model error for {model.id}: {e}")
|
||||
return False, normalized
|
||||
297
open_notebook/ai/key_provider.py
Normal file
297
open_notebook/ai/key_provider.py
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"""
|
||||
API Key Provider - Database-first with environment fallback.
|
||||
|
||||
This module provides a unified interface for retrieving API keys and provider
|
||||
configuration. It reads from Credential records (individual per-provider
|
||||
credentials) and falls back to environment variables for backward compatibility.
|
||||
|
||||
Usage:
|
||||
from open_notebook.ai.key_provider import provision_provider_keys
|
||||
|
||||
# Call before model provisioning to set env vars from DB
|
||||
await provision_provider_keys("openai")
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from open_notebook.domain.credential import Credential
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider Configuration Mapping
|
||||
# =============================================================================
|
||||
# Maps provider names to their environment variable names.
|
||||
# This is the single source of truth for provider-to-env-var mapping.
|
||||
|
||||
PROVIDER_CONFIG = {
|
||||
# Simple providers (just API key)
|
||||
"openai": {
|
||||
"env_var": "OPENAI_API_KEY",
|
||||
},
|
||||
"anthropic": {
|
||||
"env_var": "ANTHROPIC_API_KEY",
|
||||
},
|
||||
"google": {
|
||||
"env_var": "GOOGLE_API_KEY",
|
||||
},
|
||||
"groq": {
|
||||
"env_var": "GROQ_API_KEY",
|
||||
},
|
||||
"mistral": {
|
||||
"env_var": "MISTRAL_API_KEY",
|
||||
},
|
||||
"deepseek": {
|
||||
"env_var": "DEEPSEEK_API_KEY",
|
||||
},
|
||||
"xai": {
|
||||
"env_var": "XAI_API_KEY",
|
||||
},
|
||||
"openrouter": {
|
||||
"env_var": "OPENROUTER_API_KEY",
|
||||
},
|
||||
"voyage": {
|
||||
"env_var": "VOYAGE_API_KEY",
|
||||
},
|
||||
"elevenlabs": {
|
||||
"env_var": "ELEVENLABS_API_KEY",
|
||||
},
|
||||
# URL-based providers
|
||||
"ollama": {
|
||||
"env_var": "OLLAMA_API_BASE",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _get_default_credential(provider: str) -> Optional[Credential]:
|
||||
"""Get the first credential for a provider from the database."""
|
||||
try:
|
||||
credentials = await Credential.get_by_provider(provider)
|
||||
if credentials:
|
||||
return credentials[0]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not load credential from database for {provider}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def get_api_key(provider: str) -> Optional[str]:
|
||||
"""
|
||||
Get API key for a provider. Checks database first, then env var.
|
||||
|
||||
Args:
|
||||
provider: Provider name (openai, anthropic, etc.)
|
||||
|
||||
Returns:
|
||||
API key string or None if not configured
|
||||
"""
|
||||
cred = await _get_default_credential(provider)
|
||||
if cred and cred.api_key:
|
||||
logger.debug(f"Using {provider} API key from Credential")
|
||||
return cred.api_key.get_secret_value()
|
||||
|
||||
# Fall back to environment variable
|
||||
config_info = PROVIDER_CONFIG.get(provider.lower())
|
||||
if config_info:
|
||||
env_value = os.environ.get(config_info["env_var"])
|
||||
if env_value:
|
||||
logger.debug(f"Using {provider} API key from environment variable")
|
||||
return env_value
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _provision_simple_provider(provider: str) -> bool:
|
||||
"""
|
||||
Set environment variable for a simple provider from DB config.
|
||||
|
||||
Returns:
|
||||
True if key was set from database, False otherwise
|
||||
"""
|
||||
provider_lower = provider.lower()
|
||||
config_info = PROVIDER_CONFIG.get(provider_lower)
|
||||
if not config_info:
|
||||
return False
|
||||
|
||||
env_var = config_info["env_var"]
|
||||
|
||||
cred = await _get_default_credential(provider_lower)
|
||||
if not cred:
|
||||
return False
|
||||
|
||||
# Set API key / primary env var
|
||||
if cred.api_key:
|
||||
os.environ[env_var] = cred.api_key.get_secret_value()
|
||||
logger.debug(f"Set {env_var} from Credential")
|
||||
|
||||
# Set base URL if present
|
||||
if cred.base_url:
|
||||
provider_upper = provider_lower.upper()
|
||||
os.environ[f"{provider_upper}_API_BASE"] = cred.base_url
|
||||
logger.debug(f"Set {provider_upper}_API_BASE from Credential")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def _provision_vertex() -> bool:
|
||||
"""
|
||||
Set environment variables for Google Vertex AI from DB config.
|
||||
|
||||
Returns:
|
||||
True if any keys were set from database
|
||||
"""
|
||||
any_set = False
|
||||
|
||||
cred = await _get_default_credential("vertex")
|
||||
if not cred:
|
||||
return False
|
||||
|
||||
if cred.project:
|
||||
os.environ["VERTEX_PROJECT"] = cred.project
|
||||
logger.debug("Set VERTEX_PROJECT from Credential")
|
||||
any_set = True
|
||||
if cred.location:
|
||||
os.environ["VERTEX_LOCATION"] = cred.location
|
||||
logger.debug("Set VERTEX_LOCATION from Credential")
|
||||
any_set = True
|
||||
if cred.credentials_path:
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = cred.credentials_path
|
||||
logger.debug("Set GOOGLE_APPLICATION_CREDENTIALS from Credential")
|
||||
any_set = True
|
||||
|
||||
return any_set
|
||||
|
||||
|
||||
async def _provision_azure() -> bool:
|
||||
"""
|
||||
Set environment variables for Azure OpenAI from DB config.
|
||||
|
||||
Returns:
|
||||
True if any keys were set from database
|
||||
"""
|
||||
any_set = False
|
||||
|
||||
cred = await _get_default_credential("azure")
|
||||
if not cred:
|
||||
return False
|
||||
|
||||
if cred.api_key:
|
||||
os.environ["AZURE_OPENAI_API_KEY"] = cred.api_key.get_secret_value()
|
||||
logger.debug("Set AZURE_OPENAI_API_KEY from Credential")
|
||||
any_set = True
|
||||
if cred.api_version:
|
||||
os.environ["AZURE_OPENAI_API_VERSION"] = cred.api_version
|
||||
logger.debug("Set AZURE_OPENAI_API_VERSION from Credential")
|
||||
any_set = True
|
||||
if cred.endpoint:
|
||||
os.environ["AZURE_OPENAI_ENDPOINT"] = cred.endpoint
|
||||
logger.debug("Set AZURE_OPENAI_ENDPOINT from Credential")
|
||||
any_set = True
|
||||
if cred.endpoint_llm:
|
||||
os.environ["AZURE_OPENAI_ENDPOINT_LLM"] = cred.endpoint_llm
|
||||
logger.debug("Set AZURE_OPENAI_ENDPOINT_LLM from Credential")
|
||||
any_set = True
|
||||
if cred.endpoint_embedding:
|
||||
os.environ["AZURE_OPENAI_ENDPOINT_EMBEDDING"] = cred.endpoint_embedding
|
||||
logger.debug("Set AZURE_OPENAI_ENDPOINT_EMBEDDING from Credential")
|
||||
any_set = True
|
||||
if cred.endpoint_stt:
|
||||
os.environ["AZURE_OPENAI_ENDPOINT_STT"] = cred.endpoint_stt
|
||||
logger.debug("Set AZURE_OPENAI_ENDPOINT_STT from Credential")
|
||||
any_set = True
|
||||
if cred.endpoint_tts:
|
||||
os.environ["AZURE_OPENAI_ENDPOINT_TTS"] = cred.endpoint_tts
|
||||
logger.debug("Set AZURE_OPENAI_ENDPOINT_TTS from Credential")
|
||||
any_set = True
|
||||
|
||||
return any_set
|
||||
|
||||
|
||||
async def _provision_openai_compatible() -> bool:
|
||||
"""
|
||||
Set environment variables for OpenAI-Compatible providers from DB config.
|
||||
|
||||
Returns:
|
||||
True if any keys were set from database
|
||||
"""
|
||||
any_set = False
|
||||
|
||||
cred = await _get_default_credential("openai_compatible")
|
||||
if not cred:
|
||||
return False
|
||||
|
||||
if cred.api_key:
|
||||
os.environ["OPENAI_COMPATIBLE_API_KEY"] = cred.api_key.get_secret_value()
|
||||
logger.debug("Set OPENAI_COMPATIBLE_API_KEY from Credential")
|
||||
any_set = True
|
||||
if cred.base_url:
|
||||
os.environ["OPENAI_COMPATIBLE_BASE_URL"] = cred.base_url
|
||||
logger.debug("Set OPENAI_COMPATIBLE_BASE_URL from Credential")
|
||||
any_set = True
|
||||
|
||||
return any_set
|
||||
|
||||
|
||||
async def provision_provider_keys(provider: str) -> bool:
|
||||
"""
|
||||
Provision environment variables from database for a specific provider.
|
||||
|
||||
This function checks if the provider has a Credential record stored in the
|
||||
database and sets the corresponding environment variables. If the database
|
||||
doesn't have the configuration, existing environment variables remain unchanged.
|
||||
|
||||
This is the main entry point for the DB->Env fallback mechanism.
|
||||
|
||||
Args:
|
||||
provider: Provider name (openai, anthropic, azure, vertex,
|
||||
openai-compatible, etc.)
|
||||
|
||||
Returns:
|
||||
True if any keys were set from database, False otherwise
|
||||
|
||||
Example:
|
||||
# Before provisioning a model, ensure DB keys are in env vars
|
||||
await provision_provider_keys("openai")
|
||||
model = AIFactory.create_language(model_name="gpt-4", provider="openai")
|
||||
"""
|
||||
# Normalize provider name
|
||||
provider_lower = provider.lower()
|
||||
|
||||
# Handle complex providers with multiple config fields
|
||||
if provider_lower == "vertex":
|
||||
return await _provision_vertex()
|
||||
elif provider_lower == "azure":
|
||||
return await _provision_azure()
|
||||
elif provider_lower in ("openai-compatible", "openai_compatible"):
|
||||
return await _provision_openai_compatible()
|
||||
|
||||
# Handle simple providers
|
||||
return await _provision_simple_provider(provider_lower)
|
||||
|
||||
|
||||
async def provision_all_keys() -> dict[str, bool]:
|
||||
"""
|
||||
Provision environment variables from database for all providers.
|
||||
|
||||
NOTE: This function is deprecated for request-time use because it can leave
|
||||
stale env vars after key deletion. Keys should only be provisioned at startup
|
||||
or via provision_provider_keys() for specific providers.
|
||||
|
||||
Useful at application startup to load all DB-stored keys into environment.
|
||||
|
||||
Returns:
|
||||
Dict mapping provider names to whether keys were set from DB
|
||||
"""
|
||||
results: dict[str, bool] = {}
|
||||
|
||||
# Simple providers
|
||||
for provider in PROVIDER_CONFIG.keys():
|
||||
results[provider] = await provision_provider_keys(provider)
|
||||
|
||||
# Complex providers
|
||||
results["vertex"] = await provision_provider_keys("vertex")
|
||||
results["azure"] = await provision_provider_keys("azure")
|
||||
results["openai_compatible"] = await provision_provider_keys("openai_compatible")
|
||||
|
||||
return results
|
||||
756
open_notebook/ai/model_discovery.py
Normal file
756
open_notebook/ai/model_discovery.py
Normal file
|
|
@ -0,0 +1,756 @@
|
|||
"""
|
||||
Model Discovery - Automatic model fetching from AI providers.
|
||||
|
||||
This module provides functionality to discover available models from configured
|
||||
AI providers and automatically register them in the database.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from loguru import logger
|
||||
|
||||
from open_notebook.ai.models import Model
|
||||
from open_notebook.domain.credential import Credential
|
||||
from open_notebook.database.repository import repo_query
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiscoveredModel:
|
||||
"""Represents a model discovered from a provider."""
|
||||
|
||||
name: str
|
||||
provider: str
|
||||
model_type: str # language, embedding, speech_to_text, text_to_speech
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider-Specific Model Type Classification
|
||||
# =============================================================================
|
||||
# These mappings help classify models by their capabilities based on naming patterns
|
||||
|
||||
OPENAI_MODEL_TYPES = {
|
||||
"language": [
|
||||
"gpt-4",
|
||||
"gpt-3.5",
|
||||
"o1",
|
||||
"o3",
|
||||
"chatgpt",
|
||||
"text-davinci",
|
||||
"davinci",
|
||||
"curie",
|
||||
"babbage",
|
||||
"ada",
|
||||
],
|
||||
"embedding": ["text-embedding", "embedding"],
|
||||
"speech_to_text": ["whisper"],
|
||||
"text_to_speech": ["tts"],
|
||||
}
|
||||
|
||||
ANTHROPIC_MODELS = {
|
||||
# Static list since Anthropic doesn't have a model listing API
|
||||
"language": [
|
||||
"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",
|
||||
],
|
||||
}
|
||||
|
||||
GOOGLE_MODEL_TYPES = {
|
||||
"language": ["gemini", "palm", "bison", "chat"],
|
||||
"embedding": ["embedding", "textembedding"],
|
||||
}
|
||||
|
||||
OLLAMA_MODEL_TYPES = {
|
||||
# Ollama models can do multiple things, classify by common names
|
||||
"language": [
|
||||
"llama",
|
||||
"mistral",
|
||||
"mixtral",
|
||||
"codellama",
|
||||
"phi",
|
||||
"gemma",
|
||||
"qwen",
|
||||
"deepseek",
|
||||
"vicuna",
|
||||
"falcon",
|
||||
"orca",
|
||||
"neural",
|
||||
"dolphin",
|
||||
"openchat",
|
||||
"starling",
|
||||
"solar",
|
||||
"yi",
|
||||
"nous",
|
||||
"wizard",
|
||||
"zephyr",
|
||||
"tinyllama",
|
||||
],
|
||||
"embedding": ["nomic-embed", "mxbai-embed", "all-minilm", "bge-", "e5-"],
|
||||
}
|
||||
|
||||
MISTRAL_MODEL_TYPES = {
|
||||
"language": [
|
||||
"mistral",
|
||||
"mixtral",
|
||||
"codestral",
|
||||
"ministral",
|
||||
"pixtral",
|
||||
"open-mistral",
|
||||
"open-mixtral",
|
||||
],
|
||||
"embedding": ["mistral-embed"],
|
||||
}
|
||||
|
||||
GROQ_MODEL_TYPES = {
|
||||
"language": ["llama", "mixtral", "gemma", "whisper"],
|
||||
"speech_to_text": ["whisper"],
|
||||
}
|
||||
|
||||
DEEPSEEK_MODEL_TYPES = {
|
||||
"language": ["deepseek-chat", "deepseek-reasoner", "deepseek-coder"],
|
||||
}
|
||||
|
||||
XAI_MODEL_TYPES = {
|
||||
"language": ["grok"],
|
||||
}
|
||||
|
||||
VOYAGE_MODEL_TYPES = {
|
||||
"embedding": ["voyage"],
|
||||
}
|
||||
|
||||
ELEVENLABS_MODEL_TYPES = {
|
||||
"text_to_speech": ["eleven"],
|
||||
}
|
||||
|
||||
|
||||
def classify_model_type(model_name: str, provider: str) -> str:
|
||||
"""
|
||||
Classify a model into a type based on its name and provider.
|
||||
|
||||
Returns one of: language, embedding, speech_to_text, text_to_speech
|
||||
"""
|
||||
name_lower = model_name.lower()
|
||||
|
||||
type_mappings = {
|
||||
"openai": OPENAI_MODEL_TYPES,
|
||||
"google": GOOGLE_MODEL_TYPES,
|
||||
"ollama": OLLAMA_MODEL_TYPES,
|
||||
"mistral": MISTRAL_MODEL_TYPES,
|
||||
"groq": GROQ_MODEL_TYPES,
|
||||
"deepseek": DEEPSEEK_MODEL_TYPES,
|
||||
"xai": XAI_MODEL_TYPES,
|
||||
"voyage": VOYAGE_MODEL_TYPES,
|
||||
"elevenlabs": ELEVENLABS_MODEL_TYPES,
|
||||
}
|
||||
|
||||
mapping = type_mappings.get(provider, {})
|
||||
|
||||
# Check each type in order of specificity
|
||||
for model_type in ["speech_to_text", "text_to_speech", "embedding", "language"]:
|
||||
patterns = mapping.get(model_type, [])
|
||||
for pattern in patterns:
|
||||
if pattern in name_lower:
|
||||
return model_type
|
||||
|
||||
# Default to language for unknown models
|
||||
return "language"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Provider-Specific Model Discovery Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def discover_openai_models() -> List[DiscoveredModel]:
|
||||
"""Fetch available models from OpenAI API."""
|
||||
api_key = os.environ.get("OPENAI_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
models = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.openai.com/v1/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if model_id:
|
||||
model_type = classify_model_type(model_id, "openai")
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_id,
|
||||
provider="openai",
|
||||
model_type=model_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to discover OpenAI models: {e}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def discover_anthropic_models() -> List[DiscoveredModel]:
|
||||
"""Return static list of Anthropic models (no discovery API available)."""
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
# Anthropic doesn't have a model listing API, so we use a static list
|
||||
models = []
|
||||
for model_name in ANTHROPIC_MODELS.get("language", []):
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_name,
|
||||
provider="anthropic",
|
||||
model_type="language",
|
||||
)
|
||||
)
|
||||
return models
|
||||
|
||||
|
||||
async def discover_google_models() -> List[DiscoveredModel]:
|
||||
"""Fetch available models from Google Gemini API."""
|
||||
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
models = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Build URL without logging the key to avoid exposure
|
||||
url = "https://generativelanguage.googleapis.com/v1/models"
|
||||
headers = {"X-Goog-Api-Key": api_key}
|
||||
response = await client.get(url, headers=headers, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
for model in data.get("models", []):
|
||||
# Google returns full path like "models/gemini-1.5-flash"
|
||||
model_name = model.get("name", "").replace("models/", "")
|
||||
if model_name:
|
||||
model_type = classify_model_type(model_name, "google")
|
||||
# Check supported generation methods for better classification
|
||||
methods = model.get("supportedGenerationMethods", [])
|
||||
if "embedContent" in methods:
|
||||
model_type = "embedding"
|
||||
elif "generateContent" in methods:
|
||||
model_type = "language"
|
||||
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_name,
|
||||
provider="google",
|
||||
model_type=model_type,
|
||||
description=model.get("displayName"),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
# Log without exposing the API key in the message
|
||||
logger.warning(f"Failed to discover Google models: {type(e).__name__}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def discover_ollama_models() -> List[DiscoveredModel]:
|
||||
"""Fetch available models from local Ollama instance."""
|
||||
base_url = os.environ.get("OLLAMA_API_BASE", "http://localhost:11434")
|
||||
if not base_url:
|
||||
return []
|
||||
|
||||
models = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{base_url}/api/tags",
|
||||
timeout=10.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
for model in data.get("models", []):
|
||||
model_name = model.get("name", "")
|
||||
if model_name:
|
||||
model_type = classify_model_type(model_name, "ollama")
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_name,
|
||||
provider="ollama",
|
||||
model_type=model_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to discover Ollama models: {e}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def discover_groq_models() -> List[DiscoveredModel]:
|
||||
"""Fetch available models from Groq API."""
|
||||
api_key = os.environ.get("GROQ_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
models = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.groq.com/openai/v1/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if model_id:
|
||||
model_type = classify_model_type(model_id, "groq")
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_id,
|
||||
provider="groq",
|
||||
model_type=model_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to discover Groq models: {e}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def discover_mistral_models() -> List[DiscoveredModel]:
|
||||
"""Fetch available models from Mistral API."""
|
||||
api_key = os.environ.get("MISTRAL_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
models = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.mistral.ai/v1/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if model_id:
|
||||
model_type = classify_model_type(model_id, "mistral")
|
||||
# Check capabilities if available
|
||||
capabilities = model.get("capabilities", {})
|
||||
if capabilities.get("completion_chat"):
|
||||
model_type = "language"
|
||||
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_id,
|
||||
provider="mistral",
|
||||
model_type=model_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to discover Mistral models: {e}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def discover_deepseek_models() -> List[DiscoveredModel]:
|
||||
"""Fetch available models from DeepSeek API."""
|
||||
api_key = os.environ.get("DEEPSEEK_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
models = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.deepseek.com/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if model_id:
|
||||
model_type = classify_model_type(model_id, "deepseek")
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_id,
|
||||
provider="deepseek",
|
||||
model_type=model_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to discover DeepSeek models: {e}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def discover_xai_models() -> List[DiscoveredModel]:
|
||||
"""Fetch available models from xAI API."""
|
||||
api_key = os.environ.get("XAI_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
models = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://api.x.ai/v1/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if model_id:
|
||||
model_type = classify_model_type(model_id, "xai")
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_id,
|
||||
provider="xai",
|
||||
model_type=model_type,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to discover xAI models: {e}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def discover_openrouter_models() -> List[DiscoveredModel]:
|
||||
"""Fetch available models from OpenRouter API."""
|
||||
api_key = os.environ.get("OPENROUTER_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
models = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if model_id:
|
||||
# OpenRouter models are typically language models
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_id,
|
||||
provider="openrouter",
|
||||
model_type="language",
|
||||
description=model.get("name"),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to discover OpenRouter models: {e}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
async def discover_voyage_models() -> List[DiscoveredModel]:
|
||||
"""Return static list of Voyage AI models (embedding only)."""
|
||||
api_key = os.environ.get("VOYAGE_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
# Voyage AI specializes in embeddings
|
||||
voyage_models = [
|
||||
"voyage-3",
|
||||
"voyage-3-lite",
|
||||
"voyage-code-3",
|
||||
"voyage-finance-2",
|
||||
"voyage-law-2",
|
||||
"voyage-multilingual-2",
|
||||
]
|
||||
|
||||
return [
|
||||
DiscoveredModel(name=m, provider="voyage", model_type="embedding")
|
||||
for m in voyage_models
|
||||
]
|
||||
|
||||
|
||||
async def discover_elevenlabs_models() -> List[DiscoveredModel]:
|
||||
"""Return static list of ElevenLabs TTS models."""
|
||||
api_key = os.environ.get("ELEVENLABS_API_KEY")
|
||||
if not api_key:
|
||||
return []
|
||||
|
||||
# ElevenLabs specializes in TTS
|
||||
elevenlabs_models = [
|
||||
"eleven_multilingual_v2",
|
||||
"eleven_turbo_v2_5",
|
||||
"eleven_turbo_v2",
|
||||
"eleven_monolingual_v1",
|
||||
"eleven_multilingual_v1",
|
||||
]
|
||||
|
||||
return [
|
||||
DiscoveredModel(name=m, provider="elevenlabs", model_type="text_to_speech")
|
||||
for m in elevenlabs_models
|
||||
]
|
||||
|
||||
|
||||
async def discover_openai_compatible_models() -> List[DiscoveredModel]:
|
||||
"""
|
||||
Fetch available models from an OpenAI-compatible API endpoint.
|
||||
Uses the configured base_url from the database or environment variable.
|
||||
"""
|
||||
api_key = None
|
||||
base_url = None
|
||||
|
||||
# Try to get config from Credential database first
|
||||
try:
|
||||
credentials = await Credential.get_by_provider("openai_compatible")
|
||||
if credentials:
|
||||
cred = credentials[0]
|
||||
config = cred.to_esperanto_config()
|
||||
api_key = config.get("api_key")
|
||||
base_url = config.get("base_url", "").rstrip("/")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read openai_compatible config from Credential: {e}")
|
||||
|
||||
# Fall back to environment variables
|
||||
if not api_key:
|
||||
api_key = os.environ.get("OPENAI_COMPATIBLE_API_KEY")
|
||||
if not base_url:
|
||||
base_url = os.environ.get("OPENAI_COMPATIBLE_BASE_URL", "").rstrip("/")
|
||||
|
||||
if not base_url:
|
||||
logger.warning("No base_url configured for openai_compatible provider")
|
||||
return []
|
||||
|
||||
models = []
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
response = await client.get(
|
||||
f"{base_url}/models",
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
if model_id:
|
||||
# Classify based on model name patterns
|
||||
model_type = classify_model_type(model_id, "openai")
|
||||
models.append(
|
||||
DiscoveredModel(
|
||||
name=model_id,
|
||||
provider="openai_compatible",
|
||||
model_type=model_type,
|
||||
)
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.warning(f"Failed to discover openai_compatible models: HTTP {e.response.status_code}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to discover openai_compatible models: {e}")
|
||||
|
||||
return models
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Discovery Functions
|
||||
# =============================================================================
|
||||
|
||||
# Map provider names to their discovery functions
|
||||
PROVIDER_DISCOVERY_FUNCTIONS = {
|
||||
"openai": discover_openai_models,
|
||||
"anthropic": discover_anthropic_models,
|
||||
"google": discover_google_models,
|
||||
"ollama": discover_ollama_models,
|
||||
"groq": discover_groq_models,
|
||||
"mistral": discover_mistral_models,
|
||||
"deepseek": discover_deepseek_models,
|
||||
"xai": discover_xai_models,
|
||||
"openrouter": discover_openrouter_models,
|
||||
"voyage": discover_voyage_models,
|
||||
"elevenlabs": discover_elevenlabs_models,
|
||||
"openai_compatible": discover_openai_compatible_models,
|
||||
"azure": None, # Azure requires credential-based discovery (different auth)
|
||||
"vertex": None, # Vertex requires credential-based discovery (service account)
|
||||
}
|
||||
|
||||
|
||||
async def discover_provider_models(provider: str) -> List[DiscoveredModel]:
|
||||
"""
|
||||
Discover available models for a specific provider.
|
||||
|
||||
Args:
|
||||
provider: Provider name (openai, anthropic, etc.)
|
||||
|
||||
Returns:
|
||||
List of discovered models
|
||||
"""
|
||||
discover_func = PROVIDER_DISCOVERY_FUNCTIONS.get(provider)
|
||||
if discover_func is None:
|
||||
if provider in PROVIDER_DISCOVERY_FUNCTIONS:
|
||||
logger.info(
|
||||
f"Provider '{provider}' requires credential-based discovery. "
|
||||
f"Use the /credentials/{{id}}/discover endpoint instead."
|
||||
)
|
||||
else:
|
||||
logger.warning(f"No discovery function for provider: {provider}")
|
||||
return []
|
||||
|
||||
return await discover_func()
|
||||
|
||||
|
||||
async def sync_provider_models(
|
||||
provider: str, auto_register: bool = True
|
||||
) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Sync models for a provider: discover and optionally register in database.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
auto_register: If True, automatically create Model records in database
|
||||
|
||||
Returns:
|
||||
Tuple of (discovered_count, new_count, existing_count)
|
||||
"""
|
||||
discovered = await discover_provider_models(provider)
|
||||
discovered_count = len(discovered)
|
||||
new_count = 0
|
||||
existing_count = 0
|
||||
|
||||
if not auto_register:
|
||||
return discovered_count, 0, 0
|
||||
|
||||
if not discovered:
|
||||
return 0, 0, 0
|
||||
|
||||
# Batch fetch existing models to avoid N+1 query pattern
|
||||
try:
|
||||
existing_models = await repo_query(
|
||||
"SELECT string::lowercase(name) as name, string::lowercase(type) as type FROM model "
|
||||
"WHERE string::lowercase(provider) = $provider",
|
||||
{"provider": provider.lower()},
|
||||
)
|
||||
# Create a set of (name, type) tuples for O(1) lookup
|
||||
existing_keys = set()
|
||||
for m in existing_models:
|
||||
existing_keys.add((m.get("name", ""), m.get("type", "")))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch existing models for {provider}: {e}")
|
||||
existing_keys = set()
|
||||
|
||||
for model in discovered:
|
||||
model_key = (model.name.lower(), model.model_type.lower())
|
||||
|
||||
# Check if model already exists using pre-fetched data
|
||||
if model_key in existing_keys:
|
||||
existing_count += 1
|
||||
continue
|
||||
|
||||
# Create new model
|
||||
try:
|
||||
new_model = Model(
|
||||
name=model.name,
|
||||
provider=model.provider,
|
||||
type=model.model_type,
|
||||
)
|
||||
await new_model.save()
|
||||
new_count += 1
|
||||
logger.info(f"Registered new model: {model.provider}/{model.name} ({model.model_type})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register model {model.name}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"Synced {provider}: {discovered_count} discovered, "
|
||||
f"{new_count} new, {existing_count} existing"
|
||||
)
|
||||
return discovered_count, new_count, existing_count
|
||||
|
||||
|
||||
async def sync_all_providers() -> Dict[str, Tuple[int, int, int]]:
|
||||
"""
|
||||
Sync models for all configured providers.
|
||||
|
||||
Returns:
|
||||
Dict mapping provider names to (discovered, new, existing) tuples
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Run discovery for all providers in parallel
|
||||
tasks = []
|
||||
providers = list(PROVIDER_DISCOVERY_FUNCTIONS.keys())
|
||||
|
||||
for provider in providers:
|
||||
tasks.append(sync_provider_models(provider, auto_register=True))
|
||||
|
||||
task_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for provider, result in zip(providers, task_results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"Error syncing {provider}: {result}")
|
||||
results[provider] = (0, 0, 0)
|
||||
else:
|
||||
results[provider] = result
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def get_provider_model_count(provider: str) -> Dict[str, int]:
|
||||
"""
|
||||
Get count of registered models for a provider, grouped by type.
|
||||
|
||||
Args:
|
||||
provider: Provider name (case-insensitive)
|
||||
|
||||
Returns:
|
||||
Dict mapping model type to count
|
||||
"""
|
||||
# Use case-insensitive comparison by lowercasing the provider
|
||||
result = await repo_query(
|
||||
"SELECT type, count() as count FROM model WHERE string::lowercase(provider) = string::lowercase($provider) GROUP BY type",
|
||||
{"provider": provider},
|
||||
)
|
||||
|
||||
counts = {
|
||||
"language": 0,
|
||||
"embedding": 0,
|
||||
"speech_to_text": 0,
|
||||
"text_to_speech": 0,
|
||||
}
|
||||
|
||||
for row in result:
|
||||
model_type = row.get("type")
|
||||
count = row.get("count", 0)
|
||||
if model_type in counts:
|
||||
counts[model_type] = count
|
||||
|
||||
return counts
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from typing import ClassVar, Dict, Optional, Union
|
||||
from typing import Any, ClassVar, Dict, Optional, Union
|
||||
|
||||
from esperanto import (
|
||||
AIFactory,
|
||||
|
|
@ -17,9 +17,11 @@ ModelType = Union[LanguageModel, EmbeddingModel, SpeechToTextModel, TextToSpeech
|
|||
|
||||
class Model(ObjectModel):
|
||||
table_name: ClassVar[str] = "model"
|
||||
nullable_fields: ClassVar[set[str]] = {"credential"}
|
||||
name: str
|
||||
provider: str
|
||||
type: str
|
||||
credential: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
async def get_models_by_type(cls, model_type):
|
||||
|
|
@ -28,6 +30,33 @@ class Model(ObjectModel):
|
|||
)
|
||||
return [Model(**model) for model in models]
|
||||
|
||||
@classmethod
|
||||
async def get_by_credential(cls, credential_id: str):
|
||||
"""Get all models linked to a specific credential."""
|
||||
models = await repo_query(
|
||||
"SELECT * FROM model WHERE credential=$cred_id;",
|
||||
{"cred_id": ensure_record_id(credential_id)},
|
||||
)
|
||||
return [Model(**model) for model in models]
|
||||
|
||||
def _prepare_save_data(self) -> Dict[str, Any]:
|
||||
data = super()._prepare_save_data()
|
||||
if data.get("credential"):
|
||||
data["credential"] = ensure_record_id(data["credential"])
|
||||
return data
|
||||
|
||||
async def get_credential_obj(self):
|
||||
"""Get the Credential object linked to this model, if any."""
|
||||
if not self.credential:
|
||||
return None
|
||||
from open_notebook.domain.credential import Credential
|
||||
|
||||
try:
|
||||
return await Credential.get(self.credential)
|
||||
except Exception:
|
||||
logger.warning(f"Could not load credential {self.credential} for model {self.id}")
|
||||
return None
|
||||
|
||||
|
||||
class DefaultModels(RecordModel):
|
||||
record_id: ClassVar[str] = "open_notebook:default_models"
|
||||
|
|
@ -87,30 +116,60 @@ class ModelManager:
|
|||
]:
|
||||
raise ValueError(f"Invalid model type: {model.type}")
|
||||
|
||||
# Build config from credential if linked, otherwise fall back to env vars
|
||||
config: dict = {}
|
||||
if model.credential:
|
||||
credential = await model.get_credential_obj()
|
||||
if credential:
|
||||
config = credential.to_esperanto_config()
|
||||
logger.debug(
|
||||
f"Using credential '{credential.name}' for model {model.name}"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Model {model.id} has credential {model.credential} but it could not be loaded. "
|
||||
f"Falling back to env vars."
|
||||
)
|
||||
# Fall back to env var provisioning
|
||||
from open_notebook.ai.key_provider import provision_provider_keys
|
||||
|
||||
await provision_provider_keys(model.provider)
|
||||
else:
|
||||
# No credential linked - use env var fallback
|
||||
from open_notebook.ai.key_provider import provision_provider_keys
|
||||
|
||||
await provision_provider_keys(model.provider)
|
||||
|
||||
# Merge any additional kwargs (e.g. temperature)
|
||||
config.update(kwargs)
|
||||
|
||||
# Normalize provider name: DB stores underscores but Esperanto expects hyphens
|
||||
provider = model.provider.replace("_", "-")
|
||||
|
||||
# Create model based on type (Esperanto will cache the instance)
|
||||
if model.type == "language":
|
||||
return AIFactory.create_language(
|
||||
model_name=model.name,
|
||||
provider=model.provider,
|
||||
config=kwargs,
|
||||
provider=provider,
|
||||
config=config,
|
||||
)
|
||||
elif model.type == "embedding":
|
||||
return AIFactory.create_embedding(
|
||||
model_name=model.name,
|
||||
provider=model.provider,
|
||||
config=kwargs,
|
||||
provider=provider,
|
||||
config=config,
|
||||
)
|
||||
elif model.type == "speech_to_text":
|
||||
return AIFactory.create_speech_to_text(
|
||||
model_name=model.name,
|
||||
provider=model.provider,
|
||||
config=kwargs,
|
||||
provider=provider,
|
||||
config=config,
|
||||
)
|
||||
elif model.type == "text_to_speech":
|
||||
return AIFactory.create_text_to_speech(
|
||||
model_name=model.name,
|
||||
provider=model.provider,
|
||||
config=kwargs,
|
||||
provider=provider,
|
||||
config=config,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid model type: {model.type}")
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ Both leverage connection context manager for lifecycle management and automatic
|
|||
- `run_one_down()`: Rollback latest migration
|
||||
|
||||
- `AsyncMigrationManager`: Main orchestrator
|
||||
- Loads 9 up migrations + 9 down migrations (hard-coded in __init__)
|
||||
- Loads 12 up migrations + 12 down migrations (hard-coded in __init__; migrations 11-12 add credential table and model-credential link)
|
||||
- `get_current_version()`: Query max version from _sbl_migrations table
|
||||
- `needs_migration()`: Boolean check (current < total migrations available)
|
||||
- `run_migration_up()`: Run all pending migrations with logging
|
||||
|
|
@ -87,7 +87,7 @@ Both leverage connection context manager for lifecycle management and automatic
|
|||
## Important Quirks & Gotchas
|
||||
|
||||
- **No connection pooling**: Each repo_* operation creates new connection; adequate for HTTP request-scoped operations but inefficient for bulk workloads
|
||||
- **Hard-coded migration files**: AsyncMigrationManager lists migrations 1-9 explicitly; adding new migration requires code change (not auto-discovery)
|
||||
- **Hard-coded migration files**: AsyncMigrationManager lists migrations 1-12 explicitly; adding new migration requires code change (not auto-discovery)
|
||||
- **Record ID format inconsistency**: repo_update() accepts both `table:id` format and full RecordID; path handling can be subtle
|
||||
- **ISO date parsing**: repo_update() parses `created` field from string to datetime if present; assumes ISO format
|
||||
- **Timestamp overwrite risk**: repo_create() always sets new timestamps; can't preserve original created time on reimport
|
||||
|
|
|
|||
|
|
@ -106,6 +106,12 @@ class AsyncMigrationManager:
|
|||
AsyncMigration.from_file("open_notebook/database/migrations/8.surrealql"),
|
||||
AsyncMigration.from_file("open_notebook/database/migrations/9.surrealql"),
|
||||
AsyncMigration.from_file("open_notebook/database/migrations/10.surrealql"),
|
||||
AsyncMigration.from_file(
|
||||
"open_notebook/database/migrations/11.surrealql"
|
||||
),
|
||||
AsyncMigration.from_file(
|
||||
"open_notebook/database/migrations/12.surrealql"
|
||||
),
|
||||
]
|
||||
self.down_migrations = [
|
||||
AsyncMigration.from_file(
|
||||
|
|
@ -138,6 +144,12 @@ class AsyncMigrationManager:
|
|||
AsyncMigration.from_file(
|
||||
"open_notebook/database/migrations/10_down.surrealql"
|
||||
),
|
||||
AsyncMigration.from_file(
|
||||
"open_notebook/database/migrations/11_down.surrealql"
|
||||
),
|
||||
AsyncMigration.from_file(
|
||||
"open_notebook/database/migrations/12_down.surrealql"
|
||||
),
|
||||
]
|
||||
self.runner = AsyncMigrationRunner(
|
||||
up_migrations=self.up_migrations,
|
||||
|
|
|
|||
10
open_notebook/database/migrations/11.surrealql
Normal file
10
open_notebook/database/migrations/11.surrealql
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
-- Migration 11: Create provider configuration singleton record
|
||||
-- This record stores multiple API key configurations per provider
|
||||
-- The data is managed by the ProviderConfig RecordModel class
|
||||
|
||||
-- Create the provider configs singleton record for multi-config support
|
||||
-- This record stores multiple API key configurations per provider
|
||||
-- The data is managed by the ProviderConfig RecordModel class
|
||||
UPSERT open_notebook:provider_configs CONTENT {
|
||||
credentials: {}
|
||||
};
|
||||
4
open_notebook/database/migrations/11_down.surrealql
Normal file
4
open_notebook/database/migrations/11_down.surrealql
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
-- Rollback Migration 11: Remove provider configuration records
|
||||
|
||||
-- Remove provider configs singleton (if exists)
|
||||
DELETE open_notebook:provider_configs;
|
||||
29
open_notebook/database/migrations/12.surrealql
Normal file
29
open_notebook/database/migrations/12.surrealql
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-- Migration 12: Create credential table and add credential link to model table
|
||||
-- Individual credential records replace the ProviderConfig singleton
|
||||
-- Each credential stores API key and provider-specific configuration
|
||||
|
||||
|
||||
DEFINE TABLE credential SCHEMAFULL;
|
||||
DEFINE FIELD name ON credential TYPE string;
|
||||
DEFINE FIELD provider ON credential TYPE string;
|
||||
DEFINE FIELD modalities ON credential TYPE array DEFAULT [];
|
||||
DEFINE FIELD modalities.* ON credential TYPE string;
|
||||
DEFINE FIELD api_key ON credential TYPE option<string>;
|
||||
DEFINE FIELD base_url ON credential TYPE option<string>;
|
||||
DEFINE FIELD endpoint ON credential TYPE option<string>;
|
||||
DEFINE FIELD api_version ON credential TYPE option<string>;
|
||||
DEFINE FIELD endpoint_llm ON credential TYPE option<string>;
|
||||
DEFINE FIELD endpoint_embedding ON credential TYPE option<string>;
|
||||
DEFINE FIELD endpoint_stt ON credential TYPE option<string>;
|
||||
DEFINE FIELD endpoint_tts ON credential TYPE option<string>;
|
||||
DEFINE FIELD project ON credential TYPE option<string>;
|
||||
DEFINE FIELD location ON credential TYPE option<string>;
|
||||
DEFINE FIELD credentials_path ON credential TYPE option<string>;
|
||||
DEFINE FIELD created ON credential TYPE option<datetime> DEFAULT time::now();
|
||||
DEFINE FIELD updated ON credential TYPE option<datetime> DEFAULT time::now();
|
||||
|
||||
-- Index for fast provider lookups
|
||||
DEFINE INDEX idx_credential_provider ON credential FIELDS provider;
|
||||
|
||||
-- Add optional credential link to model table
|
||||
DEFINE FIELD credential ON model TYPE option<record<credential>>;
|
||||
5
open_notebook/database/migrations/12_down.surrealql
Normal file
5
open_notebook/database/migrations/12_down.surrealql
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-- Rollback Migration 12: Remove credential table and credential field from model
|
||||
|
||||
REMOVE FIELD credential ON TABLE model;
|
||||
REMOVE INDEX idx_credential_provider ON credential;
|
||||
REMOVE TABLE credential;
|
||||
|
|
@ -53,6 +53,23 @@ Two base classes support different persistence patterns: **ObjectModel** (mutabl
|
|||
- **Transformation**: Reusable prompts for content transformation
|
||||
- **DefaultPrompts**: Singleton with transformation instructions
|
||||
|
||||
### credential.py
|
||||
- **Credential**: Individual credential records for API keys and provider configuration
|
||||
- **One record per credential**: Each credential (e.g., "My OpenAI Key", "Work Anthropic") is a separate `Credential` record in SurrealDB
|
||||
- **Fields**: name, provider, modalities (list), api_key (SecretStr), base_url, endpoint, api_version, endpoint_llm/embedding/stt/tts, project, location, credentials_path
|
||||
- **SecretStr protection**: API key field uses Pydantic's `SecretStr` (values masked in logs/repr)
|
||||
- **Encryption integration**: Uses `encrypt_value()`/`decrypt_value()` from `open_notebook.utils.encryption`
|
||||
- Keys encrypted with Fernet before database storage
|
||||
- Requires `OPEN_NOTEBOOK_ENCRYPTION_KEY` environment variable (warns if not set)
|
||||
- **Key methods**:
|
||||
- `to_esperanto_config()`: Builds config dict for Esperanto's AIFactory methods
|
||||
- `get_by_provider(provider)`: Class method to fetch all credentials for a provider
|
||||
- `get_linked_models()`: Returns all Model records linked to this credential
|
||||
- **Custom serialization**: `_prepare_save_data()` extracts SecretStr values and encrypts before storage
|
||||
- **Decryption on read**: `get()` and `get_all()` overridden to decrypt api_key after fetch
|
||||
|
||||
- **Note**: `provider_config.py` still exists for legacy migration support (migrating old ProviderConfig records to Credential)
|
||||
|
||||
## Important Patterns
|
||||
|
||||
- **Async/await**: All DB operations async; always use await
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
"""
|
||||
Domain models for Open Notebook.
|
||||
|
||||
This module exports the core domain models used throughout the application.
|
||||
"""
|
||||
|
||||
__all__: list[str] = []
|
||||
199
open_notebook/domain/credential.py
Normal file
199
open_notebook/domain/credential.py
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
"""
|
||||
Credential domain model for storing individual provider credentials.
|
||||
|
||||
Each credential is a standalone record in the 'credential' table, replacing
|
||||
the old ProviderConfig singleton. Credentials store API keys (encrypted at
|
||||
rest) and provider-specific configuration fields.
|
||||
|
||||
Usage:
|
||||
cred = Credential(
|
||||
name="Production",
|
||||
provider="openai",
|
||||
modalities=["language", "embedding"],
|
||||
api_key=SecretStr("sk-..."),
|
||||
)
|
||||
await cred.save()
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import SecretStr
|
||||
|
||||
from open_notebook.database.repository import ensure_record_id, repo_query
|
||||
from open_notebook.domain.base import ObjectModel
|
||||
from open_notebook.utils.encryption import decrypt_value, encrypt_value
|
||||
|
||||
|
||||
class Credential(ObjectModel):
|
||||
"""
|
||||
Individual credential record for an AI provider.
|
||||
|
||||
Each record stores authentication and configuration for a single provider
|
||||
account. Models link to credentials via the credential field.
|
||||
"""
|
||||
|
||||
table_name: ClassVar[str] = "credential"
|
||||
nullable_fields: ClassVar[set[str]] = {
|
||||
"api_key",
|
||||
"base_url",
|
||||
"endpoint",
|
||||
"api_version",
|
||||
"endpoint_llm",
|
||||
"endpoint_embedding",
|
||||
"endpoint_stt",
|
||||
"endpoint_tts",
|
||||
"project",
|
||||
"location",
|
||||
"credentials_path",
|
||||
}
|
||||
|
||||
name: str
|
||||
provider: str
|
||||
modalities: List[str] = []
|
||||
api_key: Optional[SecretStr] = None
|
||||
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
|
||||
|
||||
def to_esperanto_config(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Build config dict for AIFactory.create_*() calls.
|
||||
|
||||
Returns a dict that can be passed as the 'config' parameter to
|
||||
Esperanto's AIFactory methods, overriding env var lookup.
|
||||
"""
|
||||
config: Dict[str, Any] = {}
|
||||
if self.api_key:
|
||||
config["api_key"] = self.api_key.get_secret_value()
|
||||
if self.base_url:
|
||||
config["base_url"] = self.base_url
|
||||
if self.endpoint:
|
||||
config["endpoint"] = self.endpoint
|
||||
if self.api_version:
|
||||
config["api_version"] = self.api_version
|
||||
if self.endpoint_llm:
|
||||
config["endpoint_llm"] = self.endpoint_llm
|
||||
if self.endpoint_embedding:
|
||||
config["endpoint_embedding"] = self.endpoint_embedding
|
||||
if self.endpoint_stt:
|
||||
config["endpoint_stt"] = self.endpoint_stt
|
||||
if self.endpoint_tts:
|
||||
config["endpoint_tts"] = self.endpoint_tts
|
||||
if self.project:
|
||||
config["project"] = self.project
|
||||
if self.location:
|
||||
config["location"] = self.location
|
||||
if self.credentials_path:
|
||||
config["credentials_path"] = self.credentials_path
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
async def get_by_provider(cls, provider: str) -> List["Credential"]:
|
||||
"""Get all credentials for a provider."""
|
||||
results = await repo_query(
|
||||
"SELECT * FROM credential WHERE string::lowercase(provider) = string::lowercase($provider) ORDER BY created ASC",
|
||||
{"provider": provider},
|
||||
)
|
||||
credentials = []
|
||||
for row in results:
|
||||
try:
|
||||
cred = cls._from_db_row(row)
|
||||
credentials.append(cred)
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping invalid credential: {e}")
|
||||
return credentials
|
||||
|
||||
@classmethod
|
||||
async def get(cls, id: str) -> "Credential":
|
||||
"""Override get() to handle api_key decryption."""
|
||||
instance = await super().get(id)
|
||||
# Pydantic auto-wraps the raw DB string in SecretStr, so we need
|
||||
# to extract, decrypt, and re-wrap regardless of type.
|
||||
if instance.api_key:
|
||||
raw = (
|
||||
instance.api_key.get_secret_value()
|
||||
if isinstance(instance.api_key, SecretStr)
|
||||
else instance.api_key
|
||||
)
|
||||
decrypted = decrypt_value(raw)
|
||||
object.__setattr__(instance, "api_key", SecretStr(decrypted))
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
async def get_all(cls, order_by=None) -> List["Credential"]:
|
||||
"""Override get_all() to handle api_key decryption."""
|
||||
instances = await super().get_all(order_by=order_by)
|
||||
for instance in instances:
|
||||
if instance.api_key:
|
||||
raw = (
|
||||
instance.api_key.get_secret_value()
|
||||
if isinstance(instance.api_key, SecretStr)
|
||||
else instance.api_key
|
||||
)
|
||||
decrypted = decrypt_value(raw)
|
||||
object.__setattr__(instance, "api_key", SecretStr(decrypted))
|
||||
return instances
|
||||
|
||||
async def get_linked_models(self) -> list:
|
||||
"""Get all models linked to this credential."""
|
||||
if not self.id:
|
||||
return []
|
||||
from open_notebook.ai.models import Model
|
||||
|
||||
results = await repo_query(
|
||||
"SELECT * FROM model WHERE credential = $cred_id",
|
||||
{"cred_id": ensure_record_id(self.id)},
|
||||
)
|
||||
return [Model(**row) for row in results]
|
||||
|
||||
def _prepare_save_data(self) -> Dict[str, Any]:
|
||||
"""Override to encrypt api_key before storage."""
|
||||
data = {}
|
||||
for key, value in self.model_dump().items():
|
||||
if key == "api_key":
|
||||
# Handle SecretStr: extract, encrypt, store
|
||||
if self.api_key:
|
||||
secret_value = self.api_key.get_secret_value()
|
||||
data["api_key"] = encrypt_value(secret_value)
|
||||
else:
|
||||
data["api_key"] = None
|
||||
elif value is not None or key in self.__class__.nullable_fields:
|
||||
data[key] = value
|
||||
|
||||
return data
|
||||
|
||||
async def save(self) -> None:
|
||||
"""Save credential, handling api_key re-hydration after DB round-trip."""
|
||||
# Remember the original SecretStr before save
|
||||
original_api_key = self.api_key
|
||||
|
||||
await super().save()
|
||||
|
||||
# After save, the api_key field may be set to the encrypted string
|
||||
# from the DB result. Restore the original SecretStr.
|
||||
if original_api_key:
|
||||
object.__setattr__(self, "api_key", original_api_key)
|
||||
elif self.api_key and isinstance(self.api_key, str):
|
||||
# Decrypt if DB returned an encrypted string
|
||||
decrypted = decrypt_value(self.api_key)
|
||||
object.__setattr__(self, "api_key", SecretStr(decrypted))
|
||||
|
||||
@classmethod
|
||||
def _from_db_row(cls, row: dict) -> "Credential":
|
||||
"""Create a Credential from a database row, decrypting api_key."""
|
||||
api_key_val = row.get("api_key")
|
||||
if api_key_val and isinstance(api_key_val, str):
|
||||
decrypted = decrypt_value(api_key_val)
|
||||
row["api_key"] = SecretStr(decrypted)
|
||||
elif api_key_val is None:
|
||||
row["api_key"] = None
|
||||
return cls(**row)
|
||||
444
open_notebook/domain/provider_config.py
Normal file
444
open_notebook/domain/provider_config.py
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
"""
|
||||
Provider Configuration domain model for storing multiple credentials per provider.
|
||||
|
||||
This module provides the ProviderConfig singleton model that stores multiple
|
||||
API key configurations per provider. Each ProviderCredential contains a complete
|
||||
set of configuration options for a provider (api_key, base_url, model, etc.).
|
||||
|
||||
Encryption is enabled when OPEN_NOTEBOOK_ENCRYPTION_KEY environment variable
|
||||
is set. If not set, keys are stored as plain text with a warning logged.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import ClassVar, Dict, List, Optional
|
||||
|
||||
from pydantic import Field, SecretStr, field_validator
|
||||
|
||||
from open_notebook.database.repository import ensure_record_id, repo_query, repo_upsert
|
||||
from open_notebook.domain.base import RecordModel
|
||||
from open_notebook.utils.encryption import decrypt_value, encrypt_value
|
||||
|
||||
|
||||
class ProviderCredential:
|
||||
"""
|
||||
A single provider configuration item containing api_key and related settings.
|
||||
|
||||
This class represents one complete configuration for an AI provider.
|
||||
Multiple configurations can exist for the same provider, allowing users
|
||||
to have different credentials for different environments (dev, prod, etc.).
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for this configuration
|
||||
name: Human-readable name for this configuration
|
||||
provider: Provider name (e.g., "openai", "anthropic")
|
||||
is_default: Whether this is the default configuration for the provider
|
||||
api_key: The API key (stored as SecretStr for in-memory protection)
|
||||
base_url: Base URL for the provider API
|
||||
model: Default model to use for this provider
|
||||
api_version: API version string (for providers that need it)
|
||||
endpoint: Generic endpoint URL
|
||||
endpoint_llm: Endpoint URL for LLM service
|
||||
endpoint_embedding: Endpoint URL for embedding service
|
||||
endpoint_stt: Endpoint URL for speech-to-text service
|
||||
endpoint_tts: Endpoint URL for text-to-speech service
|
||||
project: Project ID (for Vertex AI)
|
||||
location: Location/region (for Vertex AI)
|
||||
credentials_path: Path to credentials file (for Vertex AI)
|
||||
created: Timestamp when this config was created
|
||||
updated: Timestamp when this config was last updated
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id: str,
|
||||
name: str,
|
||||
provider: str,
|
||||
is_default: bool = False,
|
||||
api_key: Optional[SecretStr] = None,
|
||||
base_url: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
api_version: Optional[str] = None,
|
||||
endpoint: 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,
|
||||
created: Optional[str] = None,
|
||||
updated: Optional[str] = None,
|
||||
):
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.provider = provider
|
||||
self.is_default = is_default
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.model = model
|
||||
self.api_version = api_version
|
||||
self.endpoint = endpoint
|
||||
self.endpoint_llm = endpoint_llm
|
||||
self.endpoint_embedding = endpoint_embedding
|
||||
self.endpoint_stt = endpoint_stt
|
||||
self.endpoint_tts = endpoint_tts
|
||||
self.project = project
|
||||
self.location = location
|
||||
self.credentials_path = credentials_path
|
||||
self.created = created or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.updated = updated or datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
def to_dict(self, encrypted: bool = False) -> dict:
|
||||
"""
|
||||
Convert the credential to a dictionary for storage.
|
||||
|
||||
Args:
|
||||
encrypted: If True, api_key is encrypted; otherwise it's a SecretStr
|
||||
|
||||
Returns:
|
||||
Dictionary representation of the credential
|
||||
"""
|
||||
data = {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"provider": self.provider,
|
||||
"is_default": self.is_default,
|
||||
"base_url": self.base_url,
|
||||
"model": self.model,
|
||||
"api_version": self.api_version,
|
||||
"endpoint": self.endpoint,
|
||||
"endpoint_llm": self.endpoint_llm,
|
||||
"endpoint_embedding": self.endpoint_embedding,
|
||||
"endpoint_stt": self.endpoint_stt,
|
||||
"endpoint_tts": self.endpoint_tts,
|
||||
"project": self.project,
|
||||
"location": self.location,
|
||||
"credentials_path": self.credentials_path,
|
||||
"created": self.created,
|
||||
"updated": self.updated,
|
||||
}
|
||||
|
||||
if self.api_key:
|
||||
if encrypted:
|
||||
data["api_key"] = encrypt_value(self.api_key.get_secret_value())
|
||||
else:
|
||||
data["api_key"] = self.api_key.get_secret_value()
|
||||
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict, decrypted: bool = False) -> "ProviderCredential":
|
||||
"""
|
||||
Create a ProviderCredential from a dictionary.
|
||||
|
||||
Args:
|
||||
data: Dictionary containing credential data
|
||||
decrypted: If True, api_key is already decrypted; otherwise wrap in SecretStr
|
||||
|
||||
Returns:
|
||||
ProviderCredential instance
|
||||
"""
|
||||
api_key = None
|
||||
if "api_key" in data and data["api_key"]:
|
||||
if isinstance(data["api_key"], SecretStr):
|
||||
# Already a SecretStr - use as-is
|
||||
api_key = data["api_key"]
|
||||
elif decrypted:
|
||||
# Decrypted string from DB - wrap in SecretStr
|
||||
api_key = SecretStr(data["api_key"])
|
||||
else:
|
||||
# Encrypted string from DB - wrap in SecretStr (will be decrypted later)
|
||||
api_key = SecretStr(data["api_key"])
|
||||
|
||||
return cls(
|
||||
id=data["id"],
|
||||
name=data["name"],
|
||||
provider=data["provider"],
|
||||
is_default=data.get("is_default", False),
|
||||
api_key=api_key,
|
||||
base_url=data.get("base_url"),
|
||||
model=data.get("model"),
|
||||
api_version=data.get("api_version"),
|
||||
endpoint=data.get("endpoint"),
|
||||
endpoint_llm=data.get("endpoint_llm"),
|
||||
endpoint_embedding=data.get("endpoint_embedding"),
|
||||
endpoint_stt=data.get("endpoint_stt"),
|
||||
endpoint_tts=data.get("endpoint_tts"),
|
||||
project=data.get("project"),
|
||||
location=data.get("location"),
|
||||
credentials_path=data.get("credentials_path"),
|
||||
created=data.get("created"),
|
||||
updated=data.get("updated"),
|
||||
)
|
||||
|
||||
|
||||
class ProviderConfig(RecordModel):
|
||||
"""
|
||||
Singleton configuration for multiple provider credentials.
|
||||
|
||||
Uses RecordModel pattern with a fixed record_id. Stores a dictionary
|
||||
of ProviderCredential objects organized by provider name.
|
||||
|
||||
Usage:
|
||||
config = await ProviderConfig.get_instance()
|
||||
credentials = config.credentials.get("openai", [])
|
||||
default = config.get_default_config("openai")
|
||||
"""
|
||||
|
||||
record_id: ClassVar[str] = "open_notebook:provider_configs"
|
||||
|
||||
# Store credentials organized by provider name
|
||||
# Structure: {"openai": [ProviderCredential, ...], "anthropic": [...], ...}
|
||||
credentials: Dict[str, List[ProviderCredential]] = Field(
|
||||
default_factory=dict,
|
||||
description="Provider credentials organized by provider name",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "ProviderConfig":
|
||||
"""
|
||||
Always fetch fresh configuration from database.
|
||||
|
||||
Overrides parent caching behavior to ensure we always get the latest
|
||||
configuration values.
|
||||
|
||||
Returns:
|
||||
ProviderConfig: Fresh instance with current database values
|
||||
"""
|
||||
result = await repo_query(
|
||||
"SELECT * FROM ONLY $record_id",
|
||||
{"record_id": ensure_record_id(cls.record_id)},
|
||||
)
|
||||
|
||||
if result:
|
||||
if isinstance(result, list) and len(result) > 0:
|
||||
data = result[0]
|
||||
elif isinstance(result, dict):
|
||||
data = result
|
||||
else:
|
||||
data = {}
|
||||
else:
|
||||
data = {}
|
||||
|
||||
# Initialize credentials from database data
|
||||
credentials: Dict[str, List[ProviderCredential]] = {}
|
||||
creds_data = data.get("credentials")
|
||||
if creds_data and isinstance(creds_data, dict):
|
||||
for provider, provider_creds in creds_data.items():
|
||||
if isinstance(provider_creds, list):
|
||||
credentials[provider] = []
|
||||
for cred_data in provider_creds:
|
||||
try:
|
||||
# Decrypt api_key if it's a string
|
||||
api_key_val = cred_data.get("api_key")
|
||||
if api_key_val and isinstance(api_key_val, str):
|
||||
decrypted = decrypt_value(api_key_val)
|
||||
cred_data["api_key"] = SecretStr(decrypted)
|
||||
else:
|
||||
# Keep as SecretStr or None
|
||||
if api_key_val:
|
||||
cred_data["api_key"] = SecretStr(api_key_val)
|
||||
else:
|
||||
cred_data["api_key"] = None
|
||||
|
||||
credentials[provider].append(
|
||||
ProviderCredential(
|
||||
id=cred_data.get("id", ""),
|
||||
name=cred_data.get("name", "Default"),
|
||||
provider=cred_data.get("provider", provider),
|
||||
is_default=cred_data.get("is_default", False),
|
||||
api_key=cred_data.get("api_key"),
|
||||
base_url=cred_data.get("base_url"),
|
||||
model=cred_data.get("model"),
|
||||
api_version=cred_data.get("api_version"),
|
||||
endpoint=cred_data.get("endpoint"),
|
||||
endpoint_llm=cred_data.get("endpoint_llm"),
|
||||
endpoint_embedding=cred_data.get(
|
||||
"endpoint_embedding"
|
||||
),
|
||||
endpoint_stt=cred_data.get("endpoint_stt"),
|
||||
endpoint_tts=cred_data.get("endpoint_tts"),
|
||||
project=cred_data.get("project"),
|
||||
location=cred_data.get("location"),
|
||||
credentials_path=cred_data.get("credentials_path"),
|
||||
created=cred_data.get("created"),
|
||||
updated=cred_data.get("updated"),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Skip invalid credentials
|
||||
continue
|
||||
|
||||
# Create instance using model_validate to properly initialize Pydantic model
|
||||
instance = cls.model_validate({"credentials": credentials})
|
||||
|
||||
# Mark as loaded from database
|
||||
object.__setattr__(instance, "_db_loaded", True)
|
||||
|
||||
return instance
|
||||
|
||||
def get_default_config(self, provider: str) -> Optional[ProviderCredential]:
|
||||
"""
|
||||
Get the default configuration for a provider.
|
||||
|
||||
Args:
|
||||
provider: Provider name (e.g., "openai", "anthropic")
|
||||
|
||||
Returns:
|
||||
The default ProviderCredential, or None if not found
|
||||
"""
|
||||
provider_lower = provider.lower()
|
||||
credentials = self.credentials.get(provider_lower, [])
|
||||
|
||||
# First, try to find explicitly marked default
|
||||
for cred in credentials:
|
||||
if cred.is_default:
|
||||
return cred
|
||||
|
||||
# If no explicit default, return first config
|
||||
if credentials:
|
||||
return credentials[0]
|
||||
|
||||
return None
|
||||
|
||||
def get_config(
|
||||
self, provider: str, config_id: str
|
||||
) -> Optional[ProviderCredential]:
|
||||
"""
|
||||
Get a specific configuration by ID.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
config_id: Configuration ID
|
||||
|
||||
Returns:
|
||||
The ProviderCredential if found, None otherwise
|
||||
"""
|
||||
provider_lower = provider.lower()
|
||||
credentials = self.credentials.get(provider_lower, [])
|
||||
|
||||
for cred in credentials:
|
||||
if cred.id == config_id:
|
||||
return cred
|
||||
|
||||
return None
|
||||
|
||||
def add_config(self, provider: str, credential: ProviderCredential) -> None:
|
||||
"""
|
||||
Add a new configuration for a provider.
|
||||
|
||||
If this is the first config for the provider, it becomes the default.
|
||||
When adding a new config to an existing provider, the new config becomes
|
||||
the default and previous default is unset.
|
||||
|
||||
Args:
|
||||
provider: Provider name (normalized to lowercase)
|
||||
credential: ProviderCredential to add
|
||||
"""
|
||||
provider_lower = provider.lower()
|
||||
credential.provider = provider_lower
|
||||
|
||||
if provider_lower not in self.credentials:
|
||||
self.credentials[provider_lower] = []
|
||||
|
||||
# When adding a new config to an existing provider, make it the default
|
||||
# and unset the previous default
|
||||
if self.credentials[provider_lower]:
|
||||
for cred in self.credentials[provider_lower]:
|
||||
cred.is_default = False
|
||||
credential.is_default = True
|
||||
|
||||
# If this is the first config, make it default
|
||||
if not self.credentials[provider_lower]:
|
||||
credential.is_default = True
|
||||
|
||||
self.credentials[provider_lower].append(credential)
|
||||
|
||||
def delete_config(self, provider: str, config_id: str) -> bool:
|
||||
"""
|
||||
Delete a configuration.
|
||||
|
||||
Cannot delete the default configuration unless it's the only one.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
config_id: Configuration ID to delete
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
provider_lower = provider.lower()
|
||||
credentials = self.credentials.get(provider_lower, [])
|
||||
|
||||
for i, cred in enumerate(credentials):
|
||||
if cred.id == config_id:
|
||||
# Cannot delete default if there are other configs
|
||||
if cred.is_default and len(credentials) > 1:
|
||||
return False
|
||||
|
||||
del credentials[i]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def set_default_config(self, provider: str, config_id: str) -> bool:
|
||||
"""
|
||||
Set a configuration as the default for a provider.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
config_id: Configuration ID to make default
|
||||
|
||||
Returns:
|
||||
True if successful, False if config not found
|
||||
"""
|
||||
provider_lower = provider.lower()
|
||||
credentials = self.credentials.get(provider_lower, [])
|
||||
|
||||
for cred in credentials:
|
||||
if cred.id == config_id:
|
||||
# Unset all other defaults
|
||||
for other in credentials:
|
||||
other.is_default = False
|
||||
|
||||
# Set this one as default
|
||||
cred.is_default = True
|
||||
cred.updated = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _prepare_save_data(self) -> dict:
|
||||
"""
|
||||
Prepare data for database storage.
|
||||
|
||||
SecretStr values are extracted, encrypted, and stored as strings.
|
||||
Encryption is performed using Fernet symmetric encryption if
|
||||
OPEN_NOTEBOOK_ENCRYPTION_KEY is configured.
|
||||
"""
|
||||
data = {"credentials": {}}
|
||||
|
||||
for provider, credentials in self.credentials.items():
|
||||
data["credentials"][provider] = []
|
||||
for cred in credentials:
|
||||
cred.updated = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
data["credentials"][provider].append(cred.to_dict(encrypted=True))
|
||||
|
||||
return data
|
||||
|
||||
async def save(self) -> "ProviderConfig":
|
||||
"""
|
||||
Save the configuration to the database.
|
||||
|
||||
Uses _prepare_save_data() to properly handle SecretStr conversion
|
||||
and encryption.
|
||||
"""
|
||||
data = self._prepare_save_data()
|
||||
await repo_upsert("open_notebook", self.record_id, data)
|
||||
return self
|
||||
|
||||
@classmethod
|
||||
def _clear_for_test(cls) -> None:
|
||||
"""Clear the singleton instance for testing purposes."""
|
||||
if cls.record_id in cls._instances:
|
||||
del cls._instances[cls.record_id]
|
||||
|
|
@ -192,3 +192,37 @@ context_items = await builder.build()
|
|||
for item in context_items:
|
||||
print(f"{item.type}:{item.id} ({item.token_count} tokens)")
|
||||
```
|
||||
|
||||
### encryption.py
|
||||
- **get_secret_from_env(var_name)**: Retrieve secret from environment with Docker secrets support (checks VAR_FILE first, then VAR)
|
||||
- **get_fernet()**: Get Fernet instance if encryption key is configured
|
||||
- **encrypt_value(value)**: Encrypt a string using Fernet symmetric encryption
|
||||
- **decrypt_value(value)**: Decrypt a Fernet-encrypted string; gracefully falls back to original value for legacy/unencrypted data
|
||||
**Purpose**: Provides field-level encryption for sensitive data (API keys) stored in the database. Uses Fernet symmetric encryption (AES-128-CBC with HMAC-SHA256) for authenticated encryption.
|
||||
|
||||
**Key behavior**:
|
||||
- Key source: OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE (Docker secrets) → OPEN_NOTEBOOK_ENCRYPTION_KEY (env var)
|
||||
- Accepts **any string**: always derived to a Fernet key via SHA-256
|
||||
- No default key — encryption is unavailable until the env var is set
|
||||
- Graceful fallback on decryption: InvalidToken errors (legacy unencrypted data) return the original value
|
||||
- Lazy-loaded key: initialized on first use, not at import time
|
||||
|
||||
**Security considerations**:
|
||||
- OPEN_NOTEBOOK_ENCRYPTION_KEY must be set explicitly (no default)
|
||||
- Docker secrets pattern supported for secure key injection in containerized environments
|
||||
- Key rotation would require re-encrypting all stored keys (not currently implemented)
|
||||
- Encryption is transparent to callers; unencrypted legacy data continues to work
|
||||
|
||||
**Usage Example**:
|
||||
```python
|
||||
from open_notebook.utils.encryption import encrypt_value, decrypt_value
|
||||
|
||||
# Encrypt before storing in database
|
||||
encrypted_api_key = encrypt_value(api_key)
|
||||
|
||||
# Decrypt when reading from database
|
||||
decrypted_api_key = decrypt_value(encrypted_api_key)
|
||||
|
||||
# Set any string as encryption key:
|
||||
# OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret-passphrase
|
||||
```
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ To avoid circular imports, import functions directly:
|
|||
- from open_notebook.utils import token_count, compare_versions
|
||||
- from open_notebook.utils.chunking import chunk_text, detect_content_type, ContentType
|
||||
- from open_notebook.utils.embedding import generate_embedding, generate_embeddings
|
||||
- from open_notebook.utils.encryption import encrypt_value, decrypt_value
|
||||
"""
|
||||
|
||||
from .chunking import (
|
||||
|
|
@ -21,6 +22,10 @@ from .embedding import (
|
|||
generate_embeddings,
|
||||
mean_pool_embeddings,
|
||||
)
|
||||
from .encryption import (
|
||||
decrypt_value,
|
||||
encrypt_value,
|
||||
)
|
||||
from .text_utils import (
|
||||
clean_thinking_content,
|
||||
parse_thinking_content,
|
||||
|
|
@ -58,4 +63,7 @@ __all__ = [
|
|||
"compare_versions",
|
||||
"get_installed_version",
|
||||
"get_version_from_github",
|
||||
# Encryption utils
|
||||
"decrypt_value",
|
||||
"encrypt_value",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -10,15 +10,18 @@ All embedding operations in the application should use these functions
|
|||
to ensure consistent behavior and proper handling of large content.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
|
||||
from open_notebook.ai.models import model_manager
|
||||
|
||||
from .chunking import CHUNK_SIZE, ContentType, chunk_text
|
||||
|
||||
# Lazy import to avoid circular dependency:
|
||||
# utils -> embedding -> models -> key_provider -> provider_config -> utils
|
||||
if TYPE_CHECKING:
|
||||
from open_notebook.ai.models import ModelManager
|
||||
|
||||
|
||||
async def mean_pool_embeddings(embeddings: List[List[float]]) -> List[float]:
|
||||
"""
|
||||
|
|
@ -99,6 +102,9 @@ async def generate_embeddings(
|
|||
if not texts:
|
||||
return []
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
from open_notebook.ai.models import model_manager
|
||||
|
||||
embedding_model = await model_manager.get_embedding_model()
|
||||
if not embedding_model:
|
||||
raise ValueError(
|
||||
|
|
|
|||
198
open_notebook/utils/encryption.py
Normal file
198
open_notebook/utils/encryption.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"""
|
||||
Field-level encryption for sensitive data using API keys.
|
||||
|
||||
This module provides encryption/decryption for API keys stored in the database.
|
||||
Fernet uses AES-128-CBC with HMAC-SHA256 for authenticated encryption.
|
||||
|
||||
OPEN_NOTEBOOK_ENCRYPTION_KEY accepts **any string**. A Fernet key is derived
|
||||
from it via SHA-256, so users can set a simple passphrase like
|
||||
``OPEN_NOTEBOOK_ENCRYPTION_KEY=my-secret`` and it will work.
|
||||
|
||||
Usage:
|
||||
# Encrypt before storing
|
||||
encrypted = encrypt_value(api_key)
|
||||
|
||||
# Decrypt when reading
|
||||
decrypted = decrypt_value(encrypted)
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from loguru import logger
|
||||
|
||||
|
||||
def get_secret_from_env(var_name: str) -> Optional[str]:
|
||||
"""
|
||||
Get a secret from environment, supporting Docker secrets pattern.
|
||||
|
||||
Checks for VAR_FILE first (Docker secrets), then falls back to VAR.
|
||||
|
||||
Args:
|
||||
var_name: Base name of the environment variable (e.g., "OPEN_NOTEBOOK_ENCRYPTION_KEY")
|
||||
|
||||
Returns:
|
||||
The secret value, or None if not configured.
|
||||
"""
|
||||
# Check for _FILE variant first (Docker secrets)
|
||||
file_path = os.environ.get(f"{var_name}_FILE")
|
||||
if file_path:
|
||||
try:
|
||||
path = Path(file_path)
|
||||
if path.exists() and path.is_file():
|
||||
secret = path.read_text().strip()
|
||||
if secret:
|
||||
logger.debug(f"Loaded {var_name} from file: {file_path}")
|
||||
return secret
|
||||
else:
|
||||
logger.warning(f"{var_name}_FILE points to empty file: {file_path}")
|
||||
else:
|
||||
logger.warning(f"{var_name}_FILE path does not exist: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read {var_name} from file {file_path}: {e}")
|
||||
|
||||
# Fall back to direct environment variable
|
||||
return os.environ.get(var_name)
|
||||
|
||||
|
||||
def _get_or_create_encryption_key() -> str:
|
||||
"""
|
||||
Get encryption key from environment, requires explicit configuration.
|
||||
|
||||
Priority:
|
||||
1. OPEN_NOTEBOOK_ENCRYPTION_KEY_FILE (Docker secrets)
|
||||
2. OPEN_NOTEBOOK_ENCRYPTION_KEY (environment variable)
|
||||
|
||||
For production deployments, you MUST set OPEN_NOTEBOOK_ENCRYPTION_KEY explicitly!
|
||||
|
||||
Returns:
|
||||
Encryption key string.
|
||||
|
||||
Raises:
|
||||
ValueError: If no encryption key is configured.
|
||||
"""
|
||||
# First check environment/Docker secrets
|
||||
key = get_secret_from_env("OPEN_NOTEBOOK_ENCRYPTION_KEY")
|
||||
if key:
|
||||
return key
|
||||
|
||||
raise ValueError(
|
||||
"OPEN_NOTEBOOK_ENCRYPTION_KEY is not set. "
|
||||
"Set this environment variable to any secret string to enable "
|
||||
"encrypted storage of API keys in the database."
|
||||
)
|
||||
|
||||
|
||||
# Lazy-loaded encryption key: initialized on first use, not at import time.
|
||||
# This prevents the entire app from crashing if the key is not yet configured
|
||||
# when other modules import from this file.
|
||||
_ENCRYPTION_KEY: Optional[str] = None
|
||||
|
||||
|
||||
def _get_encryption_key() -> str:
|
||||
"""Get the encryption key, initializing lazily on first call."""
|
||||
global _ENCRYPTION_KEY
|
||||
if _ENCRYPTION_KEY is None:
|
||||
_ENCRYPTION_KEY = _get_or_create_encryption_key()
|
||||
return _ENCRYPTION_KEY
|
||||
|
||||
|
||||
def _ensure_fernet_key(key: str) -> str:
|
||||
"""
|
||||
Derive a valid Fernet key from an arbitrary string via SHA-256.
|
||||
|
||||
Any string is accepted as input. The key is derived by hashing it with
|
||||
SHA-256 and encoding the result as URL-safe base64.
|
||||
"""
|
||||
derived = hashlib.sha256(key.encode()).digest()
|
||||
return base64.urlsafe_b64encode(derived).decode()
|
||||
|
||||
|
||||
def get_fernet() -> Fernet:
|
||||
"""
|
||||
Get Fernet instance with the configured encryption key.
|
||||
|
||||
Returns:
|
||||
Fernet instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If encryption key is not configured.
|
||||
"""
|
||||
return Fernet(_ensure_fernet_key(_get_encryption_key()).encode())
|
||||
|
||||
|
||||
def encrypt_value(value: str) -> str:
|
||||
"""
|
||||
Encrypt a string value using Fernet symmetric encryption.
|
||||
|
||||
Args:
|
||||
value: The plain text string to encrypt.
|
||||
|
||||
Returns:
|
||||
Base64-encoded encrypted string.
|
||||
|
||||
Raises:
|
||||
ValueError: If encryption is not configured.
|
||||
"""
|
||||
fernet = get_fernet()
|
||||
return fernet.encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def looks_like_fernet_token(s: str) -> bool:
|
||||
"""
|
||||
Check if string looks like a Fernet encrypted token.
|
||||
|
||||
Fernet tokens are versioned (1 byte) + timestamp (8 bytes) + IV (16 bytes)
|
||||
+ ciphertext (variable, multiple of 16 with PKCS7 padding) + HMAC (32 bytes).
|
||||
Minimum decoded size is 73 bytes (1+8+16+16+32) for the smallest payload.
|
||||
"""
|
||||
if len(s) < 100: # Base64 of 73 bytes = ~100 chars minimum
|
||||
return False
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(s)
|
||||
# Fernet: version(1) + timestamp(8) + IV(16) + ciphertext(>=16) + HMAC(32)
|
||||
# Minimum 73 bytes, ciphertext must be multiple of 16 (AES block size)
|
||||
if len(decoded) < 73:
|
||||
return False
|
||||
ciphertext_len = len(decoded) - 1 - 8 - 16 - 32
|
||||
return ciphertext_len > 0 and ciphertext_len % 16 == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def decrypt_value(value: str) -> str:
|
||||
"""
|
||||
Decrypt a Fernet-encrypted string value.
|
||||
|
||||
Handles graceful fallback for legacy unencrypted data.
|
||||
|
||||
Args:
|
||||
value: The encrypted string (or plain text for legacy data).
|
||||
|
||||
Returns:
|
||||
Decrypted plain text string, or original value if not encrypted.
|
||||
|
||||
Raises:
|
||||
ValueError: If encryption is not configured or if decryption fails
|
||||
for what appears to be encrypted data (wrong key).
|
||||
"""
|
||||
fernet = get_fernet()
|
||||
|
||||
try:
|
||||
return fernet.decrypt(value.encode()).decode()
|
||||
except InvalidToken:
|
||||
if looks_like_fernet_token(value):
|
||||
# Looks like encrypted data but failed to decrypt - likely wrong key
|
||||
raise ValueError(
|
||||
"Decryption failed: data appears to be encrypted but key is incorrect. "
|
||||
"Check OPEN_NOTEBOOK_ENCRYPTION_KEY configuration."
|
||||
)
|
||||
# Not a valid token - treat as legacy plaintext
|
||||
return value
|
||||
except Exception as e:
|
||||
logger.error(f"Decryption failed: {e}")
|
||||
raise ValueError(f"Decryption failed: {str(e)}")
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "open-notebook"
|
||||
version = "1.6.2"
|
||||
version = "1.7.0-rc1"
|
||||
description = "An open source implementation of a research assistant, inspired by Google Notebook LM"
|
||||
authors = [
|
||||
{name = "Luis Novo", email = "lfnovo@gmail.com"}
|
||||
|
|
@ -29,7 +29,6 @@ dependencies = [
|
|||
"langchain-groq>=1.1.1",
|
||||
"langchain_mistralai>=1.1.1",
|
||||
"langchain_deepseek>=1.0.0",
|
||||
"langchain-google-vertexai>=3.2.0",
|
||||
"tomli>=2.0.2",
|
||||
"python-dotenv>=1.0.1",
|
||||
"httpx[socks]>=0.27.0",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,18 @@ from pathlib import Path
|
|||
# Set to empty string instead of deleting to prevent it from being reloaded
|
||||
os.environ["OPEN_NOTEBOOK_PASSWORD"] = ""
|
||||
|
||||
# Load environment variables from .env file
|
||||
# This must be done BEFORE any imports that depend on environment variables
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load .env file from project root
|
||||
dotenv_path = Path(__file__).parent.parent / ".env"
|
||||
if dotenv_path.exists():
|
||||
load_dotenv(dotenv_path)
|
||||
print(f"Loaded environment variables from {dotenv_path}")
|
||||
else:
|
||||
print(f"Warning: .env file not found at {dotenv_path}")
|
||||
|
||||
# Add the project root to the Python path
|
||||
project_root = Path(__file__).parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ from open_notebook.utils.chunking import (
|
|||
detect_content_type_from_heuristics,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEST SUITE 1: Content Type Detection from Extension
|
||||
# ============================================================================
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue