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 + ? ` + ${modelOptions}` + : ``; + + const keyLabel = provider.apiKeyOptional ? 'API Key (optional):' : 'API Key:'; + const urlLabel = provider.apiKeyOptional ? 'API URL:' : `API URL ${provider.id !== 'custom' ? '(optional)' : ''}:`; providerDiv.innerHTML = `
@@ -158,18 +189,17 @@ function renderAiProviders() {
- + ${modelControl}
- +
- +
+ ${provider.helpHtml ? `

${provider.helpHtml}

` : ''}
@@ -2407,6 +2408,19 @@

Add Custom AI API

Basic Configuration

+
+ + +
+

+ 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. +

diff --git a/tests/python/test_local_openai.py b/tests/python/test_local_openai.py new file mode 100644 index 00000000..c5e56b42 --- /dev/null +++ b/tests/python/test_local_openai.py @@ -0,0 +1,48 @@ +"""Local OpenAI-compatible (Ollama) URL rewrite and provider flags.""" +from ai_analyzer import ( + dummy_key_for_local, + is_local_openai_compat, + rewrite_ai_url_for_runtime, + running_in_docker, +) + + +def test_local_openai_ids(): + assert is_local_openai_compat("local-openai") + assert is_local_openai_compat("OLLAMA") + assert is_local_openai_compat("lmstudio") + assert not is_local_openai_compat("openai") + assert not is_local_openai_compat("claude") + + +def test_dummy_key_defaults_to_ollama(): + assert dummy_key_for_local("") == "ollama" + assert dummy_key_for_local(None) == "ollama" + assert dummy_key_for_local("sk-test") == "sk-test" + + +def test_rewrite_noop_outside_docker(monkeypatch): + monkeypatch.delenv("CBQA_IN_DOCKER", raising=False) + monkeypatch.setattr("ai_analyzer.os.path.exists", lambda p: False) + url = "http://localhost:11434/v1" + assert rewrite_ai_url_for_runtime(url) == url + + +def test_rewrite_localhost_inside_docker(monkeypatch): + monkeypatch.setenv("CBQA_IN_DOCKER", "1") + assert ( + rewrite_ai_url_for_runtime("http://localhost:11434/v1") + == "http://host.docker.internal:11434/v1" + ) + assert ( + rewrite_ai_url_for_runtime("http://127.0.0.1:1234/v1/chat/completions") + == "http://host.docker.internal:1234/v1/chat/completions" + ) + + +def test_running_in_docker_env(monkeypatch): + monkeypatch.setenv("CBQA_IN_DOCKER", "true") + assert running_in_docker() is True + monkeypatch.setenv("CBQA_IN_DOCKER", "0") + monkeypatch.setattr("ai_analyzer.os.path.exists", lambda p: False) + assert running_in_docker() is False