"""Settings route""" import collections import json import logging import os import threading import time from pathlib import Path from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app, Response from web.auth import login_required, hash_password, save_credentials, load_credentials # ── Debug Console infrastructure ───────────────────────────────────────────── _debug_buffer: collections.deque = collections.deque(maxlen=2000) _debug_enabled: bool = False _debug_handler_installed: bool = False class _DebugBufferHandler(logging.Handler): """Captures log records into the in-memory debug buffer.""" def emit(self, record: logging.LogRecord) -> None: if not _debug_enabled: return try: entry: dict = { 'ts': record.created, 'level': record.levelname, 'name': record.name, 'raw': record.getMessage(), 'msg': self.format(record), } if record.exc_info: import traceback as _tb entry['exc'] = ''.join(_tb.format_exception(*record.exc_info)) _debug_buffer.append(entry) except Exception: pass def _ensure_debug_handler() -> None: global _debug_handler_installed if _debug_handler_installed: return handler = _DebugBufferHandler() handler.setLevel(logging.DEBUG) handler.setFormatter(logging.Formatter('%(name)s — %(message)s')) root = logging.getLogger() root.addHandler(handler) # Lower root level to DEBUG so records reach the handler if root.level == logging.NOTSET or root.level > logging.DEBUG: root.setLevel(logging.DEBUG) _debug_handler_installed = True settings_bp = Blueprint('settings', __name__, url_prefix='/settings') @settings_bp.route('/') @login_required def index(): config = current_app.autarch_config return render_template('settings.html', llm_backend=config.get('autarch', 'llm_backend', 'local'), llama=config.get_llama_settings(), transformers=config.get_transformers_settings(), claude=config.get_claude_settings(), huggingface=config.get_huggingface_settings(), osint=config.get_osint_settings(), pentest=config.get_pentest_settings(), upnp=config.get_upnp_settings(), debug_enabled=_debug_enabled, ) @settings_bp.route('/password', methods=['POST']) @login_required def change_password(): new_pass = request.form.get('new_password', '') confirm = request.form.get('confirm_password', '') if not new_pass or len(new_pass) < 4: flash('Password must be at least 4 characters.', 'error') return redirect(url_for('settings.index')) if new_pass != confirm: flash('Passwords do not match.', 'error') return redirect(url_for('settings.index')) creds = load_credentials() save_credentials(creds['username'], hash_password(new_pass), force_change=False) flash('Password updated.', 'success') return redirect(url_for('settings.index')) @settings_bp.route('/osint', methods=['POST']) @login_required def update_osint(): config = current_app.autarch_config config.set('osint', 'max_threads', request.form.get('max_threads', '8')) config.set('osint', 'timeout', request.form.get('timeout', '8')) config.set('osint', 'include_nsfw', 'true' if request.form.get('include_nsfw') else 'false') config.save() flash('OSINT settings updated.', 'success') return redirect(url_for('settings.index')) @settings_bp.route('/upnp', methods=['POST']) @login_required def update_upnp(): config = current_app.autarch_config config.set('upnp', 'enabled', 'true' if request.form.get('enabled') else 'false') config.set('upnp', 'internal_ip', request.form.get('internal_ip', '10.0.0.26')) config.set('upnp', 'refresh_hours', request.form.get('refresh_hours', '12')) config.set('upnp', 'mappings', request.form.get('mappings', '')) config.save() flash('UPnP settings updated.', 'success') return redirect(url_for('settings.index')) @settings_bp.route('/llm', methods=['POST']) @login_required def update_llm(): config = current_app.autarch_config backend = request.form.get('backend', 'local') if backend == 'local': config.set('llama', 'model_path', request.form.get('model_path', '')) config.set('llama', 'n_ctx', request.form.get('n_ctx', '4096')) config.set('llama', 'n_threads', request.form.get('n_threads', '4')) config.set('llama', 'n_gpu_layers', request.form.get('n_gpu_layers', '0')) config.set('llama', 'n_batch', request.form.get('n_batch', '512')) config.set('llama', 'temperature', request.form.get('temperature', '0.7')) config.set('llama', 'top_p', request.form.get('top_p', '0.9')) config.set('llama', 'top_k', request.form.get('top_k', '40')) config.set('llama', 'repeat_penalty', request.form.get('repeat_penalty', '1.1')) config.set('llama', 'max_tokens', request.form.get('max_tokens', '2048')) config.set('llama', 'seed', request.form.get('seed', '-1')) config.set('llama', 'rope_scaling_type', request.form.get('rope_scaling_type', '0')) config.set('llama', 'mirostat_mode', request.form.get('mirostat_mode', '0')) config.set('llama', 'mirostat_tau', request.form.get('mirostat_tau', '5.0')) config.set('llama', 'mirostat_eta', request.form.get('mirostat_eta', '0.1')) config.set('llama', 'flash_attn', 'true' if request.form.get('flash_attn') else 'false') config.set('llama', 'gpu_backend', request.form.get('gpu_backend', 'cpu')) elif backend == 'transformers': config.set('transformers', 'model_path', request.form.get('model_path', '')) config.set('transformers', 'device', request.form.get('device', 'auto')) config.set('transformers', 'torch_dtype', request.form.get('torch_dtype', 'auto')) config.set('transformers', 'load_in_8bit', 'true' if request.form.get('load_in_8bit') else 'false') config.set('transformers', 'load_in_4bit', 'true' if request.form.get('load_in_4bit') else 'false') config.set('transformers', 'llm_int8_enable_fp32_cpu_offload', 'true' if request.form.get('llm_int8_enable_fp32_cpu_offload') else 'false') config.set('transformers', 'device_map', request.form.get('device_map', 'auto')) config.set('transformers', 'trust_remote_code', 'true' if request.form.get('trust_remote_code') else 'false') config.set('transformers', 'use_fast_tokenizer', 'true' if request.form.get('use_fast_tokenizer') else 'false') config.set('transformers', 'padding_side', request.form.get('padding_side', 'left')) config.set('transformers', 'do_sample', 'true' if request.form.get('do_sample') else 'false') config.set('transformers', 'num_beams', request.form.get('num_beams', '1')) config.set('transformers', 'temperature', request.form.get('temperature', '0.7')) config.set('transformers', 'top_p', request.form.get('top_p', '0.9')) config.set('transformers', 'top_k', request.form.get('top_k', '40')) config.set('transformers', 'repetition_penalty', request.form.get('repetition_penalty', '1.1')) config.set('transformers', 'max_tokens', request.form.get('max_tokens', '2048')) elif backend == 'claude': config.set('claude', 'model', request.form.get('model', 'claude-sonnet-4-20250514')) api_key = request.form.get('api_key', '') if api_key: config.set('claude', 'api_key', api_key) config.set('claude', 'max_tokens', request.form.get('max_tokens', '4096')) config.set('claude', 'temperature', request.form.get('temperature', '0.7')) elif backend == 'huggingface': config.set('huggingface', 'model', request.form.get('model', 'mistralai/Mistral-7B-Instruct-v0.3')) api_key = request.form.get('api_key', '') if api_key: config.set('huggingface', 'api_key', api_key) config.set('huggingface', 'endpoint', request.form.get('endpoint', '')) config.set('huggingface', 'provider', request.form.get('provider', 'auto')) config.set('huggingface', 'max_tokens', request.form.get('max_tokens', '1024')) config.set('huggingface', 'temperature', request.form.get('temperature', '0.7')) config.set('huggingface', 'top_p', request.form.get('top_p', '0.9')) config.set('huggingface', 'top_k', request.form.get('top_k', '40')) config.set('huggingface', 'repetition_penalty', request.form.get('repetition_penalty', '1.1')) config.set('huggingface', 'do_sample', 'true' if request.form.get('do_sample') else 'false') config.set('huggingface', 'seed', request.form.get('seed', '-1')) config.set('huggingface', 'stop_sequences', request.form.get('stop_sequences', '')) elif backend == 'openai': config.set('openai', 'model', request.form.get('model', 'gpt-4o')) api_key = request.form.get('api_key', '') if api_key: config.set('openai', 'api_key', api_key) config.set('openai', 'base_url', request.form.get('base_url', 'https://api.openai.com/v1')) config.set('openai', 'max_tokens', request.form.get('max_tokens', '4096')) config.set('openai', 'temperature', request.form.get('temperature', '0.7')) config.set('openai', 'top_p', request.form.get('top_p', '1.0')) config.set('openai', 'frequency_penalty', request.form.get('frequency_penalty', '0.0')) config.set('openai', 'presence_penalty', request.form.get('presence_penalty', '0.0')) # Switch active backend config.set('autarch', 'llm_backend', backend) config.save() _log = logging.getLogger('autarch.settings') _log.info(f"[Settings] LLM backend switched to: {backend}") # Reset LLM instance so next request triggers fresh load try: from core.llm import reset_llm reset_llm() _log.info("[Settings] LLM instance reset — will reload on next chat request") except Exception as exc: _log.error(f"[Settings] reset_llm() error: {exc}", exc_info=True) flash(f'LLM backend switched to {backend} and settings saved.', 'success') return redirect(url_for('settings.llm_settings')) # ── LLM Settings Sub-Page ───────────────────────────────────────────────────── @settings_bp.route('/llm') @login_required def llm_settings(): config = current_app.autarch_config from core.paths import get_app_dir default_models_dir = str(get_app_dir() / 'models') return render_template('llm_settings.html', llm_backend=config.get('autarch', 'llm_backend', 'local'), llama=config.get_llama_settings(), transformers=config.get_transformers_settings(), claude=config.get_claude_settings(), openai=config.get_openai_settings(), huggingface=config.get_huggingface_settings(), default_models_dir=default_models_dir, ) @settings_bp.route('/llm/load', methods=['POST']) @login_required def llm_load(): """Force-load the currently configured LLM backend and return status.""" _log = logging.getLogger('autarch.settings') try: from core.llm import reset_llm, get_llm from core.config import get_config config = get_config() backend = config.get('autarch', 'llm_backend', 'local') _log.info(f"[LLM Load] Requested by user — backend: {backend}") reset_llm() llm = get_llm() model_name = llm.model_name if hasattr(llm, 'model_name') else 'unknown' _log.info(f"[LLM Load] Success — backend: {backend} | model: {model_name}") return jsonify({'ok': True, 'backend': backend, 'model_name': model_name}) except Exception as exc: _log.error(f"[LLM Load] Failed: {exc}", exc_info=True) return jsonify({'ok': False, 'error': str(exc)}) @settings_bp.route('/llm/scan-models', methods=['POST']) @login_required def llm_scan_models(): """Scan a folder for supported local model files and return a list.""" data = request.get_json(silent=True) or {} folder = data.get('folder', '').strip() if not folder: return jsonify({'ok': False, 'error': 'No folder provided'}) folder_path = Path(folder) if not folder_path.is_dir(): return jsonify({'ok': False, 'error': f'Directory not found: {folder}'}) models = [] try: # GGUF / GGML / legacy bin files (single-file models) for ext in ('*.gguf', '*.ggml', '*.bin'): for p in sorted(folder_path.glob(ext)): size_mb = p.stat().st_size / (1024 * 1024) models.append({ 'name': p.name, 'path': str(p), 'type': 'gguf' if p.suffix in ('.gguf', '.ggml') else 'bin', 'size_mb': round(size_mb, 1), }) # SafeTensors model directories (contain config.json + *.safetensors) for child in sorted(folder_path.iterdir()): if not child.is_dir(): continue has_config = (child / 'config.json').exists() has_st = any(child.glob('*.safetensors')) has_st_index = (child / 'model.safetensors.index.json').exists() if has_config and (has_st or has_st_index): total_mb = sum( p.stat().st_size for p in child.glob('*.safetensors') ) / (1024 * 1024) models.append({ 'name': child.name + '/', 'path': str(child), 'type': 'safetensors', 'size_mb': round(total_mb, 1), }) # Also scan one level of subdirectories for GGUF files for child in sorted(folder_path.iterdir()): if not child.is_dir(): continue for ext in ('*.gguf', '*.ggml'): for p in sorted(child.glob(ext)): size_mb = p.stat().st_size / (1024 * 1024) models.append({ 'name': child.name + '/' + p.name, 'path': str(p), 'type': 'gguf', 'size_mb': round(size_mb, 1), }) return jsonify({'ok': True, 'models': models, 'folder': str(folder_path)}) except PermissionError as e: return jsonify({'ok': False, 'error': f'Permission denied: {e}'}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}) @settings_bp.route('/llm/hf-verify', methods=['POST']) @login_required def llm_hf_verify(): """Verify a HuggingFace token and return account info.""" data = request.get_json(silent=True) or {} token = data.get('token', '').strip() if not token: return jsonify({'ok': False, 'error': 'No token provided'}) try: from huggingface_hub import HfApi api = HfApi(token=token) info = api.whoami() return jsonify({'ok': True, 'username': info.get('name', ''), 'email': info.get('email', '')}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}) # ── MCP Server API ─────────────────────────────────────────── @settings_bp.route('/mcp/status', methods=['POST']) @login_required def mcp_status(): try: from core.mcp_server import get_server_status, get_autarch_tools status = get_server_status() tools = [{'name': t['name'], 'description': t['description']} for t in get_autarch_tools()] return jsonify({'ok': True, 'status': status, 'tools': tools}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}) @settings_bp.route('/mcp/start', methods=['POST']) @login_required def mcp_start(): try: from core.mcp_server import start_sse_server config = current_app.autarch_config port = int(config.get('web', 'mcp_port', fallback='8081')) result = start_sse_server(port=port) return jsonify(result) except Exception as e: return jsonify({'ok': False, 'error': str(e)}) @settings_bp.route('/mcp/stop', methods=['POST']) @login_required def mcp_stop(): try: from core.mcp_server import stop_sse_server result = stop_sse_server() return jsonify(result) except Exception as e: return jsonify({'ok': False, 'error': str(e)}) @settings_bp.route('/mcp/config', methods=['POST']) @login_required def mcp_config(): try: from core.mcp_server import get_mcp_config_snippet return jsonify({'ok': True, 'config': get_mcp_config_snippet()}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}) # ── Discovery API ──────────────────────────────────────────── @settings_bp.route('/discovery/status', methods=['POST']) @login_required def discovery_status(): try: from core.discovery import get_discovery_manager mgr = get_discovery_manager() return jsonify({'ok': True, 'status': mgr.get_status()}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}) @settings_bp.route('/discovery/start', methods=['POST']) @login_required def discovery_start(): try: from core.discovery import get_discovery_manager mgr = get_discovery_manager() results = mgr.start_all() return jsonify({'ok': True, 'results': results}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}) @settings_bp.route('/discovery/stop', methods=['POST']) @login_required def discovery_stop(): try: from core.discovery import get_discovery_manager mgr = get_discovery_manager() results = mgr.stop_all() return jsonify({'ok': True, 'results': results}) except Exception as e: return jsonify({'ok': False, 'error': str(e)}) # ── Debug Console API ───────────────────────────────────────────────────────── @settings_bp.route('/debug/toggle', methods=['POST']) @login_required def debug_toggle(): """Enable or disable the debug log capture.""" global _debug_enabled data = request.get_json(silent=True) or {} _debug_enabled = bool(data.get('enabled', False)) if _debug_enabled: _ensure_debug_handler() logging.getLogger('autarch.debug').info('Debug console enabled') return jsonify({'ok': True, 'enabled': _debug_enabled}) @settings_bp.route('/debug/stream') @login_required def debug_stream(): """SSE stream — pushes new log records to the browser as they arrive.""" def generate(): sent = 0 while True: buf = list(_debug_buffer) while sent < len(buf): yield f"data: {json.dumps(buf[sent])}\n\n" sent += 1 time.sleep(0.25) return Response(generate(), mimetype='text/event-stream', headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'}) @settings_bp.route('/debug/clear', methods=['POST']) @login_required def debug_clear(): """Clear the in-memory debug buffer.""" _debug_buffer.clear() return jsonify({'ok': True}) @settings_bp.route('/debug/test', methods=['POST']) @login_required def debug_test(): """Emit one log record at each level so the user can verify the debug window.""" log = logging.getLogger('autarch.test') log.debug('DEBUG — detailed diagnostic info, variable states') log.info('INFO — normal operation: module loaded, connection established') log.warning('WARNING — something unexpected but recoverable') log.error('ERROR — an operation failed, check the details below') try: raise ValueError('Example exception to show stack trace capture') except ValueError: log.exception('EXCEPTION — error with full traceback') return jsonify({'ok': True, 'sent': 5})