diff --git a/app/Dockerfile b/app/Dockerfile
index 27ef3ff5..003d6b4c 100644
--- a/app/Dockerfile
+++ b/app/Dockerfile
@@ -15,7 +15,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
CBL_DB_DIR=/app/data \
CBL_DB_NAME=cb_tools_db \
PORT=8080 \
- CBQA_LOG_FILE=/app/logs/cbqa.log
+ CBQA_LOG_FILE=/app/logs/cbqa.log \
+ CBQA_IN_DOCKER=1
# Note: STORAGE_BACKEND is no longer needed โ Couchbase Lite is the only
# supported storage backend. The Couchbase Server SDK has been removed.
# Note: SKIP_TOON_INSTALL is intentionally NOT set. `python-toon` is now
diff --git a/app/ai_analyzer.py b/app/ai_analyzer.py
index d6da7183..867300ef 100644
--- a/app/ai_analyzer.py
+++ b/app/ai_analyzer.py
@@ -11,6 +11,7 @@
- Automatic garbage collection
"""
+import os
import time
import hashlib
import secrets
@@ -50,12 +51,52 @@
OPENAI_SDK_AVAILABLE = False
logger.warning("OpenAI SDK not installed. Please run: pip install openai")
+# Local OpenAI-compatible servers (Ollama, LM Studio, vLLM, llama.cpp).
+# These speak the OpenAI Chat Completions API and typically need no real key.
+LOCAL_OPENAI_COMPAT_IDS = frozenset({"local-openai", "ollama", "lmstudio", "vllm"})
+
+
+def is_local_openai_compat(provider: str) -> bool:
+ return (provider or "").lower() in LOCAL_OPENAI_COMPAT_IDS
+
+
+def running_in_docker() -> bool:
+ """True when Flask is inside a container (cannot use localhost for host services)."""
+ flag = os.environ.get("CBQA_IN_DOCKER", "").lower()
+ if flag in ("1", "true", "yes"):
+ return True
+ return os.path.exists("/.dockerenv")
+
+
+def rewrite_ai_url_for_runtime(url: str) -> str:
+ """Rewrite localhost/127.0.0.1 to host.docker.internal when running in Docker.
+
+ Ollama/LM Studio run on the laptop, not in the container. Docker Desktop
+ exposes the host as host.docker.internal; compose also sets extra_hosts.
+ """
+ if not url:
+ return url
+ if not running_in_docker():
+ return url
+ rewritten = url
+ for host in ("localhost", "127.0.0.1", "0.0.0.0"):
+ rewritten = rewritten.replace(f"://{host}", "://host.docker.internal")
+ if rewritten != url:
+ logger.info("rewrote AI URL for Docker host access")
+ logger.debug("ai_url_rewrite from=%s to=%s", url, rewritten)
+ return rewritten
+
+
+def dummy_key_for_local(api_key: Optional[str]) -> str:
+ """Ollama accepts any Bearer token; the proxy still sends Authorization."""
+ key = (api_key or "").strip()
+ return key if key else "ollama"
+
# ============================================================================
# PyInstaller Resource Path Helper
# ============================================================================
import sys
-import os
def get_resource_path(filename: str) -> str:
"""
@@ -2318,6 +2359,12 @@ def call_ai_provider(provider: str,
import json
logger.info("calling_ai_provider provider=%s model=%s language=%s", provider, model, language)
+
+ api_url = rewrite_ai_url_for_runtime(api_url or "")
+ if is_local_openai_compat(provider):
+ api_key = dummy_key_for_local(api_key)
+ if not api_url:
+ api_url = "http://localhost:11434/v1"
# Get dynamic max_tokens based on model
max_tokens = get_max_output_tokens(provider, model)
@@ -2329,7 +2376,7 @@ def call_ai_provider(provider: str,
# ---------------------------------------------------------
# Option 1: Use OpenAI SDK (Preferred for OpenAI/Grok)
# ---------------------------------------------------------
- if (provider == 'openai' or provider == 'grok') and OPENAI_SDK_AVAILABLE:
+ if (provider == 'openai' or provider == 'grok') and OPENAI_SDK_AVAILABLE and not is_local_openai_compat(provider):
try:
logger.debug("using_openai_sdk provider=%s", provider)
start_time = time.time()
@@ -2401,7 +2448,7 @@ def call_ai_provider(provider: str,
# ---------------------------------------------------------
# Format request payload for specific provider
- if provider == 'openai' or provider == 'grok':
+ if provider == 'openai' or provider == 'grok' or is_local_openai_compat(provider):
ai_request_payload = {
'model': model,
'messages': [
@@ -2416,8 +2463,10 @@ def call_ai_provider(provider: str,
]
}
- # OpenAI uses max_completion_tokens, Grok uses max_tokens
- if provider == 'openai':
+ # OpenAI cloud uses max_completion_tokens + JSON mode.
+ # Grok and local OpenAI-compatible servers (Ollama, LM Studio) use max_tokens
+ # and often reject response_format=json_object.
+ if provider == 'openai' and not is_local_openai_compat(provider):
ai_request_payload['max_completion_tokens'] = max_tokens
ai_request_payload['response_format'] = {'type': 'json_object'}
else:
@@ -2550,7 +2599,7 @@ def call_custom_ai_provider(
logger.debug("custom_api_name %s", custom_config.get("name"))
logger.debug("custom_api_url_configured")
- url = custom_config.get('url', '')
+ url = rewrite_ai_url_for_runtime(custom_config.get('url', ''))
model = custom_config.get('model', 'default')
auth_type = custom_config.get('authType', 'none')
request_template = custom_config.get('requestTemplate', '')
diff --git a/app/app_base.py b/app/app_base.py
index cd25451e..46536dac 100644
--- a/app/app_base.py
+++ b/app/app_base.py
@@ -727,7 +727,7 @@ def analyze_with_ai():
logger.debug(" Model: %s", model)
logger.debug(" Has API Key: %s", bool(api_key))
- if not api_key:
+ if not api_key and not ai_analyzer.is_local_openai_compat(provider):
logger.error("No API key configured for provider: %s", provider)
return jsonify({
'success': False,
@@ -1142,7 +1142,7 @@ def test_ai_api():
)
else:
# Test built-in provider
- if not api_key:
+ if not api_key and not ai_analyzer.is_local_openai_compat(provider):
return jsonify({
'success': False,
'error': 'API key is required'
@@ -1158,6 +1158,8 @@ def test_ai_api():
api_url = 'https://api.x.ai/v1'
elif provider in ('gemini', 'google'):
api_url = 'https://generativelanguage.googleapis.com/v1beta'
+ elif ai_analyzer.is_local_openai_compat(provider):
+ api_url = 'http://localhost:11434/v1'
# Set default models if not provided
if not model:
@@ -1169,6 +1171,8 @@ def test_ai_api():
model = 'grok-3-mini'
elif provider in ('gemini', 'google'):
model = 'gemini-2.5-flash'
+ elif ai_analyzer.is_local_openai_compat(provider):
+ model = 'llama3.2'
# Determine endpoint
endpoint = '/chat/completions'
@@ -1315,17 +1319,20 @@ def ai_api_call():
max_retries = data.get('maxRetries', 3)
# Validation
- if not api_key:
+ if not api_key and not ai_analyzer.is_local_openai_compat(provider):
return jsonify({
'success': False,
'error': 'API key is required'
}), 400
+ if ai_analyzer.is_local_openai_compat(provider):
+ api_key = ai_analyzer.dummy_key_for_local(api_key)
if not api_url:
return jsonify({
'success': False,
'error': 'API URL is required'
}), 400
+ api_url = ai_analyzer.rewrite_ai_url_for_runtime(api_url)
# Build full URL โ Gemini embeds {model}:generateContent in the path
if provider in ('gemini', 'google') and model:
diff --git a/app/assets/css/main.css b/app/assets/css/main.css
index 30e9ecfd..e5c2c806 100644
--- a/app/assets/css/main.css
+++ b/app/assets/css/main.css
@@ -762,6 +762,19 @@ body { display: block !important; height: auto !important; overflow: visible !im
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
}
+.ai-provider-help {
+ font-size: 12px;
+ color: #666;
+ margin: 6px 0 0 0;
+ line-height: 1.45;
+}
+.ai-provider-help code {
+ background: #f0f0f0;
+ padding: 1px 5px;
+ border-radius: 3px;
+ font-size: 11px;
+}
+
/* Default badge */
.default-badge {
background: #007bff;
diff --git a/app/assets/img/ai-logos/local-openai.svg b/app/assets/img/ai-logos/local-openai.svg
new file mode 100644
index 00000000..6ded7da5
--- /dev/null
+++ b/app/assets/img/ai-logos/local-openai.svg
@@ -0,0 +1,8 @@
+
diff --git a/app/assets/js/ai-client.js b/app/assets/js/ai-client.js
index 50f994d6..2723947c 100644
--- a/app/assets/js/ai-client.js
+++ b/app/assets/js/ai-client.js
@@ -16,7 +16,8 @@ export async function getDefaultAIProvider() {
if (preferences && preferences.aiApis && Array.isArray(preferences.aiApis)) {
const defaultProvider = preferences.aiApis[0]; // First is default
- if (defaultProvider && defaultProvider.apiKey) {
+ const localCompat = defaultProvider && ['local-openai', 'ollama', 'lmstudio', 'vllm'].includes(defaultProvider.id);
+ if (defaultProvider && (defaultProvider.apiKey || localCompat)) {
Logger.info('Using default AI provider:', defaultProvider.name);
return defaultProvider;
}
@@ -64,10 +65,12 @@ export async function callAI(options) {
Logger.info('๐ค AI API Call:', { provider, model, endpoint });
// Validation
- if (!apiKey) {
+ const localCompat = ['local-openai', 'ollama', 'lmstudio', 'vllm'].includes(provider);
+ if (!apiKey && !localCompat) {
Logger.error('API key is required');
return { success: false, error: 'API key is required' };
}
+ const resolvedKey = apiKey || (localCompat ? 'ollama' : apiKey);
if (!apiUrl) {
Logger.error('API URL is required');
@@ -83,7 +86,7 @@ export async function callAI(options) {
body: JSON.stringify({
provider,
model,
- apiKey,
+ apiKey: resolvedKey,
apiUrl,
endpoint,
method,
@@ -142,6 +145,10 @@ export async function callDefaultAI(userMessage, options = {}) {
case 'deepseek':
case 'openrouter':
case 'together':
+ case 'local-openai':
+ case 'ollama':
+ case 'lmstudio':
+ case 'vllm':
// OpenAI-compatible format
endpoint = '/chat/completions';
payload = {
@@ -204,7 +211,7 @@ export async function callDefaultAI(userMessage, options = {}) {
return callAI({
provider: provider.id,
model: provider.model,
- apiKey: provider.apiKey,
+ apiKey: provider.apiKey || (['local-openai', 'ollama', 'lmstudio', 'vllm'].includes(provider.id) ? 'ollama' : provider.apiKey),
apiUrl: provider.apiUrl,
endpoint,
payload
@@ -233,6 +240,10 @@ export function extractAIResponse(result, provider) {
case 'deepseek':
case 'openrouter':
case 'together':
+ case 'local-openai':
+ case 'ollama':
+ case 'lmstudio':
+ case 'vllm':
// OpenAI format: data.choices[0].message.content
return data.choices?.[0]?.message?.content || null;
diff --git a/app/assets/js/main-legacy.js b/app/assets/js/main-legacy.js
index 6af87c2f..e19938c3 100644
--- a/app/assets/js/main-legacy.js
+++ b/app/assets/js/main-legacy.js
@@ -27636,7 +27636,8 @@ ${info.features.map((f) => ` โข ${f}`).join("\n")}
// Add standard AI providers
aiApis.forEach((api, index) => {
- const hasApiKey = api.apiKey && api.apiKey.length > 0;
+ const localCompat = ['local-openai', 'ollama', 'lmstudio', 'vllm'].includes(api.id);
+ const hasApiKey = localCompat || (api.apiKey && api.apiKey.length > 0);
const status = hasApiKey ? 'โ
' : 'โ';
const defaultTag = index === 0 ? ' (Default)' : '';
const modelInfo = api.model ? ` - ${api.model}` : '';
diff --git a/app/assets/js/settings.js b/app/assets/js/settings.js
index 68011f5b..4eae3417 100644
--- a/app/assets/js/settings.js
+++ b/app/assets/js/settings.js
@@ -112,6 +112,27 @@ const AI_PROVIDERS = [
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash - 1M ctx' },
{ id: 'gemini-1.5-flash-8b', name: 'Gemini 1.5 Flash 8B (Fastest) - 1M ctx' }
]
+ },
+ {
+ id: 'local-openai',
+ name: 'Local OpenAI-compatible',
+ logo: 'img/ai-logos/local-openai.svg',
+ keyPlaceholder: 'optional โ Ollama ignores this',
+ defaultUrl: 'http://localhost:11434/v1',
+ apiKeyOptional: true,
+ allowCustomModel: true,
+ helpHtml: 'Talks to Ollama, LM Studio, vLLM, or llama.cpp on your laptop (not inside Docker). From Docker Desktop, localhost is rewritten to host.docker.internal. Ollama default port is 11434; LM Studio is usually 1234.',
+ models: [
+ { id: 'llama3.2', name: 'llama3.2' },
+ { id: 'llama3.1', name: 'llama3.1' },
+ { id: 'llama3', name: 'llama3' },
+ { id: 'mistral', name: 'mistral' },
+ { id: 'qwen2.5', name: 'qwen2.5' },
+ { id: 'codellama', name: 'codellama' },
+ { id: 'deepseek-r1', name: 'deepseek-r1' },
+ { id: 'phi4', name: 'phi4' },
+ { id: 'gemma3', name: 'gemma3' }
+ ]
}
];
@@ -145,6 +166,16 @@ function renderAiProviders() {
const modelOptions = provider.models.map(model =>
``
).join('');
+
+ const modelControl = provider.allowCustomModel
+ ? `
+ `
+ : ``;
+
+ const keyLabel = provider.apiKeyOptional ? 'API Key (optional):' : 'API Key:';
+ const urlLabel = provider.apiKeyOptional ? 'API URL:' : `API URL ${provider.id !== 'custom' ? '(optional)' : ''}:`;
providerDiv.innerHTML = `
${provider.helpHtml}
` : ''}
+ Pick OpenAI-compatible to pre-fill URL, request body, and response path.
+ From Docker Desktop, localhost is rewritten to host.docker.internal so Ollama on your laptop is reachable.
+