{"repo": "cangyeone/sage", "files": {"web_app/routes/llm.py": {"5ef458cab746280eb97d2dfb27bd545c578d5400": {"t": 1785746376.4386458, "n": 24876, "h": "5fc0a19131931741", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _mask_key(value):\n if not value:\n return ''\n value = str(value)\n return '****' + value[-4:] if len(value) > 4 else '****'\n\n\ndef _public_search_config(raw_search):\n from config_manager import SEARCH_PROVIDER_DEFAULTS\n raw_search = raw_search or {}\n providers = raw_search.get('providers') or {}\n out = {}\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n cfg = providers.get(key) or {}\n out[key] = {\n 'display': meta.get('display', key),\n 'description': meta.get('description', ''),\n 'requires_key': bool(meta.get('requires_key')),\n 'enabled': bool(cfg.get('enabled', meta.get('enabled', False))),\n 'api_key_masked': _mask_key(cfg.get('api_key', '')),\n 'api_base': cfg.get('api_base', ''),\n }\n return {\n 'providers': out,\n 'default_sources': raw_search.get('default_sources') or ['openalex', 'semantic_scholar'],\n }\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'search': _public_search_config(cfg_mgr.get_search_config()),\n 'app_paths': cfg_mgr.get_app_paths(),\n 'coding_agent': cfg_mgr.get_coding_agent_config(),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/config/search', methods=['GET', 'POST'])\ndef search_config():\n \"\"\"Get/update web/literature search provider configuration.\"\"\"\n from config_manager import get_config_manager, SEARCH_PROVIDER_DEFAULTS\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n search = project_cfg.setdefault('search', {})\n providers = search.setdefault('providers', {})\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n providers.setdefault(key, {'enabled': meta['enabled'], 'api_key': '', 'api_base': ''})\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n data = request.get_json(silent=True) or {}\n incoming = data.get('providers') or {}\n for key, item in incoming.items():\n if key not in SEARCH_PROVIDER_DEFAULTS or not isinstance(item, dict):\n continue\n cur = providers.setdefault(key, {'enabled': False, 'api_key': '', 'api_base': ''})\n if 'enabled' in item:\n cur['enabled'] = bool(item.get('enabled'))\n if item.get('api_key'):\n cur['api_key'] = str(item.get('api_key')).strip()\n if 'api_base' in item:\n cur['api_base'] = str(item.get('api_base') or '').strip().rstrip('/')\n if isinstance(data.get('default_sources'), list):\n search['default_sources'] = [\n s for s in data['default_sources']\n if s in SEARCH_PROVIDER_DEFAULTS\n ] or ['openalex', 'semantic_scholar']\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n\n@bp.route('/api/config/app_paths', methods=['GET', 'POST'])\ndef app_paths_config():\n \"\"\"Get/update shared Chat and Parameter Optimizer path defaults.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n paths = project_cfg.setdefault('app_paths', mgr.get_app_paths())\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'app_paths': paths})\n\n data = request.get_json(silent=True) or {}\n if 'chat_workspace_enabled' in data:\n paths['chat_workspace_enabled'] = bool(data.get('chat_workspace_enabled'))\n if 'chat_workspace_paths' in data:\n raw = data.get('chat_workspace_paths')\n if isinstance(raw, str):\n vals = [x.strip() for x in raw.replace(';', '\\n').replace(',', '\\n').splitlines() if x.strip()]\n elif isinstance(raw, list):\n vals = [str(x).strip() for x in raw if str(x).strip()]\n else:\n vals = []\n paths['chat_workspace_paths'] = vals\n for key in ('geo_workspace_root', 'geo_literature_root', 'geo_output_dir'):\n if key in data:\n paths[key] = str(data.get(key) or '').strip()\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'app_paths': paths})\n\n\n@bp.route('/api/config/coding_agent', methods=['GET', 'POST'])\ndef coding_agent_config():\n \"\"\"Get/update the project-scoped coding backend configuration.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n cfg = project_cfg.setdefault('coding_agent', mgr.get_coding_agent_config())\n\n if request.method == 'GET':\n status = {}\n try:\n from seismo_code.external_coding_agent import external_agent_status\n status = external_agent_status(cfg)\n except Exception as exc:\n status = {'available': False, 'error': str(exc)}\n return jsonify({'ok': True, 'coding_agent': cfg, 'status': status})\n\n data = request.get_json(silent=True) or {}\n backend = str(data.get('backend') or cfg.get('backend') or 'builtin').strip()\n if backend not in {'builtin', 'aider', 'openhands'}:\n return jsonify({'ok': False, 'error': f'Invalid coding agent backend: {backend}'}), 400\n cfg['backend'] = backend\n # Backend selection is the source of truth. Keep `enabled` only for older clients.\n cfg['enabled'] = backend != 'builtin'\n for key in ('command', 'model', 'extra_args'):\n if key in data:\n cfg[key] = str(data.get(key) or '').strip()\n if 'timeout_s' in data:\n try:\n cfg['timeout_s'] = max(30, min(7200, int(data.get('timeout_s') or 900)))\n except Exception:\n cfg['timeout_s'] = 900\n if 'auto_approve' in data:\n cfg['auto_approve'] = bool(data.get('auto_approve'))\n if 'use_current_llm_env' in data:\n cfg['use_current_llm_env'] = bool(data.get('use_current_llm_env'))\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'coding_agent': cfg})\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "3dda843da5bdf26f88e70417b3041523a2477544": {"t": 1785763088.8084035, "n": 24876, "h": "5fc0a19131931741", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _mask_key(value):\n if not value:\n return ''\n value = str(value)\n return '****' + value[-4:] if len(value) > 4 else '****'\n\n\ndef _public_search_config(raw_search):\n from config_manager import SEARCH_PROVIDER_DEFAULTS\n raw_search = raw_search or {}\n providers = raw_search.get('providers') or {}\n out = {}\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n cfg = providers.get(key) or {}\n out[key] = {\n 'display': meta.get('display', key),\n 'description': meta.get('description', ''),\n 'requires_key': bool(meta.get('requires_key')),\n 'enabled': bool(cfg.get('enabled', meta.get('enabled', False))),\n 'api_key_masked': _mask_key(cfg.get('api_key', '')),\n 'api_base': cfg.get('api_base', ''),\n }\n return {\n 'providers': out,\n 'default_sources': raw_search.get('default_sources') or ['openalex', 'semantic_scholar'],\n }\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'search': _public_search_config(cfg_mgr.get_search_config()),\n 'app_paths': cfg_mgr.get_app_paths(),\n 'coding_agent': cfg_mgr.get_coding_agent_config(),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/config/search', methods=['GET', 'POST'])\ndef search_config():\n \"\"\"Get/update web/literature search provider configuration.\"\"\"\n from config_manager import get_config_manager, SEARCH_PROVIDER_DEFAULTS\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n search = project_cfg.setdefault('search', {})\n providers = search.setdefault('providers', {})\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n providers.setdefault(key, {'enabled': meta['enabled'], 'api_key': '', 'api_base': ''})\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n data = request.get_json(silent=True) or {}\n incoming = data.get('providers') or {}\n for key, item in incoming.items():\n if key not in SEARCH_PROVIDER_DEFAULTS or not isinstance(item, dict):\n continue\n cur = providers.setdefault(key, {'enabled': False, 'api_key': '', 'api_base': ''})\n if 'enabled' in item:\n cur['enabled'] = bool(item.get('enabled'))\n if item.get('api_key'):\n cur['api_key'] = str(item.get('api_key')).strip()\n if 'api_base' in item:\n cur['api_base'] = str(item.get('api_base') or '').strip().rstrip('/')\n if isinstance(data.get('default_sources'), list):\n search['default_sources'] = [\n s for s in data['default_sources']\n if s in SEARCH_PROVIDER_DEFAULTS\n ] or ['openalex', 'semantic_scholar']\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n\n@bp.route('/api/config/app_paths', methods=['GET', 'POST'])\ndef app_paths_config():\n \"\"\"Get/update shared Chat and Parameter Optimizer path defaults.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n paths = project_cfg.setdefault('app_paths', mgr.get_app_paths())\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'app_paths': paths})\n\n data = request.get_json(silent=True) or {}\n if 'chat_workspace_enabled' in data:\n paths['chat_workspace_enabled'] = bool(data.get('chat_workspace_enabled'))\n if 'chat_workspace_paths' in data:\n raw = data.get('chat_workspace_paths')\n if isinstance(raw, str):\n vals = [x.strip() for x in raw.replace(';', '\\n').replace(',', '\\n').splitlines() if x.strip()]\n elif isinstance(raw, list):\n vals = [str(x).strip() for x in raw if str(x).strip()]\n else:\n vals = []\n paths['chat_workspace_paths'] = vals\n for key in ('geo_workspace_root', 'geo_literature_root', 'geo_output_dir'):\n if key in data:\n paths[key] = str(data.get(key) or '').strip()\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'app_paths': paths})\n\n\n@bp.route('/api/config/coding_agent', methods=['GET', 'POST'])\ndef coding_agent_config():\n \"\"\"Get/update the project-scoped coding backend configuration.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n cfg = project_cfg.setdefault('coding_agent', mgr.get_coding_agent_config())\n\n if request.method == 'GET':\n status = {}\n try:\n from seismo_code.external_coding_agent import external_agent_status\n status = external_agent_status(cfg)\n except Exception as exc:\n status = {'available': False, 'error': str(exc)}\n return jsonify({'ok': True, 'coding_agent': cfg, 'status': status})\n\n data = request.get_json(silent=True) or {}\n backend = str(data.get('backend') or cfg.get('backend') or 'builtin').strip()\n if backend not in {'builtin', 'aider', 'openhands'}:\n return jsonify({'ok': False, 'error': f'Invalid coding agent backend: {backend}'}), 400\n cfg['backend'] = backend\n # Backend selection is the source of truth. Keep `enabled` only for older clients.\n cfg['enabled'] = backend != 'builtin'\n for key in ('command', 'model', 'extra_args'):\n if key in data:\n cfg[key] = str(data.get(key) or '').strip()\n if 'timeout_s' in data:\n try:\n cfg['timeout_s'] = max(30, min(7200, int(data.get('timeout_s') or 900)))\n except Exception:\n cfg['timeout_s'] = 900\n if 'auto_approve' in data:\n cfg['auto_approve'] = bool(data.get('auto_approve'))\n if 'use_current_llm_env' in data:\n cfg['use_current_llm_env'] = bool(data.get('use_current_llm_env'))\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'coding_agent': cfg})\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "6de3bc7d641e6f1823ee83d5ebb94ea714480e89": {"t": 1785763090.6604872, "n": 24866, "h": "7811fc0e3d9371ed", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _mask_key(value):\n if not value:\n return ''\n value = str(value)\n return '****' + value[-4:] if len(value) > 4 else '****'\n\n\ndef _public_search_config(raw_search):\n from config_manager import SEARCH_PROVIDER_DEFAULTS\n raw_search = raw_search or {}\n providers = raw_search.get('providers') or {}\n out = {}\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n cfg = providers.get(key) or {}\n out[key] = {\n 'display': meta.get('display', key),\n 'description': meta.get('description', ''),\n 'requires_key': bool(meta.get('requires_key')),\n 'enabled': bool(cfg.get('enabled', meta.get('enabled', False))),\n 'api_key_masked': _mask_key(cfg.get('api_key', '')),\n 'api_base': cfg.get('api_base', ''),\n }\n return {\n 'providers': out,\n 'default_sources': raw_search.get('default_sources') or ['openalex', 'semantic_scholar'],\n }\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'search': _public_search_config(cfg_mgr.get_search_config()),\n 'app_paths': cfg_mgr.get_app_paths(),\n 'coding_agent': cfg_mgr.get_coding_agent_config(),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/config/search', methods=['GET', 'POST'])\ndef search_config():\n \"\"\"Get/update web/literature search provider configuration.\"\"\"\n from config_manager import get_config_manager, SEARCH_PROVIDER_DEFAULTS\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n search = project_cfg.setdefault('search', {})\n providers = search.setdefault('providers', {})\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n providers.setdefault(key, {'enabled': meta['enabled'], 'api_key': '', 'api_base': ''})\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n data = request.get_json(silent=True) or {}\n incoming = data.get('providers') or {}\n for key, item in incoming.items():\n if key not in SEARCH_PROVIDER_DEFAULTS or not isinstance(item, dict):\n continue\n cur = providers.setdefault(key, {'enabled': False, 'api_key': '', 'api_base': ''})\n if 'enabled' in item:\n cur['enabled'] = bool(item.get('enabled'))\n if item.get('api_key'):\n cur['api_key'] = str(item.get('api_key')).strip()\n if 'api_base' in item:\n cur['api_base'] = str(item.get('api_base') or '').strip().rstrip('/')\n if isinstance(data.get('default_sources'), list):\n search['default_sources'] = [\n s for s in data['default_sources']\n if s in SEARCH_PROVIDER_DEFAULTS\n ] or ['openalex', 'semantic_scholar']\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n\n@bp.route('/api/config/app_paths', methods=['GET', 'POST'])\ndef app_paths_config():\n \"\"\"Get/update shared Chat and Geo Agent path defaults.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n paths = project_cfg.setdefault('app_paths', mgr.get_app_paths())\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'app_paths': paths})\n\n data = request.get_json(silent=True) or {}\n if 'chat_workspace_enabled' in data:\n paths['chat_workspace_enabled'] = bool(data.get('chat_workspace_enabled'))\n if 'chat_workspace_paths' in data:\n raw = data.get('chat_workspace_paths')\n if isinstance(raw, str):\n vals = [x.strip() for x in raw.replace(';', '\\n').replace(',', '\\n').splitlines() if x.strip()]\n elif isinstance(raw, list):\n vals = [str(x).strip() for x in raw if str(x).strip()]\n else:\n vals = []\n paths['chat_workspace_paths'] = vals\n for key in ('geo_workspace_root', 'geo_literature_root', 'geo_output_dir'):\n if key in data:\n paths[key] = str(data.get(key) or '').strip()\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'app_paths': paths})\n\n\n@bp.route('/api/config/coding_agent', methods=['GET', 'POST'])\ndef coding_agent_config():\n \"\"\"Get/update the project-scoped coding backend configuration.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n cfg = project_cfg.setdefault('coding_agent', mgr.get_coding_agent_config())\n\n if request.method == 'GET':\n status = {}\n try:\n from seismo_code.external_coding_agent import external_agent_status\n status = external_agent_status(cfg)\n except Exception as exc:\n status = {'available': False, 'error': str(exc)}\n return jsonify({'ok': True, 'coding_agent': cfg, 'status': status})\n\n data = request.get_json(silent=True) or {}\n backend = str(data.get('backend') or cfg.get('backend') or 'builtin').strip()\n if backend not in {'builtin', 'aider', 'openhands'}:\n return jsonify({'ok': False, 'error': f'Invalid coding agent backend: {backend}'}), 400\n cfg['backend'] = backend\n # Backend selection is the source of truth. Keep `enabled` only for older clients.\n cfg['enabled'] = backend != 'builtin'\n for key in ('command', 'model', 'extra_args'):\n if key in data:\n cfg[key] = str(data.get(key) or '').strip()\n if 'timeout_s' in data:\n try:\n cfg['timeout_s'] = max(30, min(7200, int(data.get('timeout_s') or 900)))\n except Exception:\n cfg['timeout_s'] = 900\n if 'auto_approve' in data:\n cfg['auto_approve'] = bool(data.get('auto_approve'))\n if 'use_current_llm_env' in data:\n cfg['use_current_llm_env'] = bool(data.get('use_current_llm_env'))\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'coding_agent': cfg})\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "bfecc1f48452eb4f1ea84a99369c06dae9f312a9": {"t": 1785763092.1301532, "n": 23018, "h": "f01fb127bc89d078", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _mask_key(value):\n if not value:\n return ''\n value = str(value)\n return '****' + value[-4:] if len(value) > 4 else '****'\n\n\ndef _public_search_config(raw_search):\n from config_manager import SEARCH_PROVIDER_DEFAULTS\n raw_search = raw_search or {}\n providers = raw_search.get('providers') or {}\n out = {}\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n cfg = providers.get(key) or {}\n out[key] = {\n 'display': meta.get('display', key),\n 'description': meta.get('description', ''),\n 'requires_key': bool(meta.get('requires_key')),\n 'enabled': bool(cfg.get('enabled', meta.get('enabled', False))),\n 'api_key_masked': _mask_key(cfg.get('api_key', '')),\n 'api_base': cfg.get('api_base', ''),\n }\n return {\n 'providers': out,\n 'default_sources': raw_search.get('default_sources') or ['openalex', 'semantic_scholar'],\n }\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'search': _public_search_config(cfg_mgr.get_search_config()),\n 'app_paths': cfg_mgr.get_app_paths(),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/config/search', methods=['GET', 'POST'])\ndef search_config():\n \"\"\"Get/update web/literature search provider configuration.\"\"\"\n from config_manager import get_config_manager, SEARCH_PROVIDER_DEFAULTS\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n search = project_cfg.setdefault('search', {})\n providers = search.setdefault('providers', {})\n for key, meta in SEARCH_PROVIDER_DEFAULTS.items():\n providers.setdefault(key, {'enabled': meta['enabled'], 'api_key': '', 'api_base': ''})\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n data = request.get_json(silent=True) or {}\n incoming = data.get('providers') or {}\n for key, item in incoming.items():\n if key not in SEARCH_PROVIDER_DEFAULTS or not isinstance(item, dict):\n continue\n cur = providers.setdefault(key, {'enabled': False, 'api_key': '', 'api_base': ''})\n if 'enabled' in item:\n cur['enabled'] = bool(item.get('enabled'))\n if item.get('api_key'):\n cur['api_key'] = str(item.get('api_key')).strip()\n if 'api_base' in item:\n cur['api_base'] = str(item.get('api_base') or '').strip().rstrip('/')\n if isinstance(data.get('default_sources'), list):\n search['default_sources'] = [\n s for s in data['default_sources']\n if s in SEARCH_PROVIDER_DEFAULTS\n ] or ['openalex', 'semantic_scholar']\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'search': _public_search_config(search)})\n\n\n@bp.route('/api/config/app_paths', methods=['GET', 'POST'])\ndef app_paths_config():\n \"\"\"Get/update shared Chat and Geo Agent path defaults.\"\"\"\n from config_manager import get_config_manager\n mgr = get_config_manager()\n project_cfg = mgr.get_project_config()\n paths = project_cfg.setdefault('app_paths', mgr.get_app_paths())\n\n if request.method == 'GET':\n return jsonify({'ok': True, 'app_paths': paths})\n\n data = request.get_json(silent=True) or {}\n if 'chat_workspace_enabled' in data:\n paths['chat_workspace_enabled'] = bool(data.get('chat_workspace_enabled'))\n if 'chat_workspace_paths' in data:\n raw = data.get('chat_workspace_paths')\n if isinstance(raw, str):\n vals = [x.strip() for x in raw.replace(';', '\\n').replace(',', '\\n').splitlines() if x.strip()]\n elif isinstance(raw, list):\n vals = [str(x).strip() for x in raw if str(x).strip()]\n else:\n vals = []\n paths['chat_workspace_paths'] = vals\n for key in ('geo_workspace_root', 'geo_literature_root', 'geo_output_dir'):\n if key in data:\n paths[key] = str(data.get(key) or '').strip()\n mgr.save_project_config(project_cfg)\n return jsonify({'ok': True, 'app_paths': paths})\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "82a35cf579f18dd06b563a9e2eb347e021da2138": {"t": 1785763095.4462836, "n": 19054, "h": "a233b5ad837d40ea", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models with a single short HTTP request.\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n recommended = config.get_recommended_models().get('ollama', [])\n ollama_cfg = config.config.get('ollama', {}) if isinstance(config.config.get('ollama', {}), dict) else {}\n llm_cfg = config.config.get('llm', {}) if isinstance(config.config.get('llm', {}), dict) else {}\n saved_llm_base = llm_cfg.get('api_base') if llm_cfg.get('provider') == 'ollama' else ''\n api_base = (\n request.args.get('api_base')\n or ollama_cfg.get('api_base')\n or saved_llm_base\n or 'http://localhost:11434'\n ).strip().rstrip('/')\n if not api_base.startswith(('http://localhost', 'http://127.0.0.1', 'http://0.0.0.0', 'http://[::1]')):\n api_base = 'http://localhost:11434'\n\n models = []\n available = False\n error = ''\n try:\n req = _ur.Request(f'{api_base}/api/tags')\n with _ur.urlopen(req, timeout=2.5) as resp:\n available = resp.status == 200\n if available:\n data = json.loads(resp.read().decode('utf-8'))\n models = _parse_model_list(data)\n except Exception as exc:\n error = str(exc)\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': available,\n 'api_base': api_base,\n 'error': error,\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "056f8ca68adc0b24d483aabf77c8ec70f28f37ab": {"t": 1785763096.9841034, "n": 17992, "h": "bcefe2c07a8c841f", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n **({\n 'notice': (\n f'已验证当前模型可用;该平台的 /models 列表接口返回 HTTP {e.code}。'\n )\n } if verified else {\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可能仍可使用;已使用当前/预设模型。'\n )\n }),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "232f6d443b92ded1e6d11315df2a99a5b18e9bc5": {"t": 1785763098.3570983, "n": 17773, "h": "23318db50c6a642f", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\ndef _parse_model_list(data):\n models = []\n if isinstance(data, dict) and isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data, dict) and isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n return sorted({m for m in models if m})\n\n\ndef _fallback_model_candidates(provider, api_base, current_model):\n candidates = []\n if current_model:\n candidates.append(current_model)\n preset = ONLINE_PROVIDERS.get(provider, {})\n if preset.get('default_model'):\n candidates.append(preset['default_model'])\n base_l = (api_base or '').lower()\n if 'aliyuncs.com' in base_l or 'dashscope' in base_l:\n candidates.extend(['qwen3.6-plus', 'qwen-plus', 'qwen-turbo'])\n return list(dict.fromkeys(candidates))\n\n\ndef _chat_model_available(api_base, api_key, model):\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 1,\n 'temperature': 0,\n }).encode('utf-8')\n req = _ur.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _ur.urlopen(req, timeout=20) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n return bool(data.get('choices'))\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key, model。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n current_model = request.args.get('model', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n if not current_model:\n current_model = (\n cfg.config.get('online', {}).get('model')\n or cfg.config.get('llm', {}).get('model', '')\n )\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = _parse_model_list(data)\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models, 'source': 'models_endpoint'})\n\n except _ure.HTTPError as e:\n body = ''\n try:\n body = e.read().decode('utf-8', errors='ignore')[:240]\n except Exception:\n pass\n msgs = {\n 401: 'API Key 无效或已过期 (401)',\n 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)',\n }\n candidates = _fallback_model_candidates(provider, api_base, current_model)\n if e.code in {400, 404, 405, 501} and candidates:\n verified = []\n for model in candidates[:4]:\n try:\n if _chat_model_available(api_base, api_key, model):\n verified.append(model)\n break\n except Exception:\n continue\n models = verified or candidates\n return jsonify({\n 'models': models,\n 'source': 'chat_probe' if verified else 'fallback',\n 'warning': (\n f'/models 接口不可用或返回 HTTP {e.code},'\n '但聊天接口可以继续使用;已使用当前/预设模型。'\n ),\n })\n\n detail = msgs.get(e.code, f'HTTP {e.code}: {e.reason}')\n if body:\n detail += f' — {body}'\n return jsonify({'error': detail, 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "2cff0b2b6a61da229ee41f5b73819021f38e3470": {"t": 1785763099.7802978, "n": 15255, "h": "d31e04154907957d", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.get_json(silent=True) or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.get_json(silent=True) or {}\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = []\n if isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n\n models = sorted([m for m in models if m])\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models})\n\n except _ure.HTTPError as e:\n msgs = {401: 'API Key 无效或已过期 (401)', 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)'}\n return jsonify({'error': msgs.get(e.code, f'HTTP {e.code}: {e.reason}'), 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "43aa9e4e69b71bb056bce2af01ec97a1243c332f": {"t": 1785763101.163703, "n": 15215, "h": "c07f261d97126ef1", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\ntry:\n from backend_manager import ONLINE_PROVIDERS\nexcept Exception:\n ONLINE_PROVIDERS = {\n \"openai\": {\"display\": \"OpenAI\", \"api_base\": \"https://api.openai.com/v1\", \"default_model\": \"gpt-4o-mini\", \"need_key\": True},\n \"deepseek\": {\"display\": \"DeepSeek\", \"api_base\": \"https://api.deepseek.com/v1\", \"default_model\": \"deepseek-v4-flash\", \"need_key\": True},\n \"siliconflow\": {\"display\": \"SiliconFlow\", \"api_base\": \"https://api.siliconflow.cn/v1\", \"default_model\": \"Qwen/Qwen2.5-7B-Instruct\", \"need_key\": True},\n \"moonshot\": {\"display\": \"Moonshot / Kimi\", \"api_base\": \"https://api.moonshot.cn/v1\", \"default_model\": \"moonshot-v1-8k\", \"need_key\": True},\n \"dashscope\": {\"display\": \"DashScope\", \"api_base\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\", \"default_model\": \"qwen-turbo\", \"need_key\": True},\n \"zhipu\": {\"display\": \"Zhipu AI\", \"api_base\": \"https://open.bigmodel.cn/api/paas/v4\", \"default_model\": \"glm-4-flash\", \"need_key\": True},\n \"custom\": {\"display\": \"Custom OpenAI-compatible\", \"api_base\": \"\", \"default_model\": \"\", \"need_key\": True},\n }\n\n\ndef _public_online_providers():\n \"\"\"Provider metadata safe to send to the browser.\"\"\"\n return {\n key: {\n 'display': value.get('display', key),\n 'api_base': value.get('api_base', ''),\n 'default_model': value.get('default_model', ''),\n 'need_key': value.get('need_key', True),\n }\n for key, value in ONLINE_PROVIDERS.items()\n if key != 'anthropic'\n }\n\n\ndef _mask_api_key(config):\n config = dict(config or {})\n if config.get('api_key'):\n key = config['api_key']\n config['api_key_masked'] = '****' + key[-4:] if len(key) > 4 else '****'\n config['api_key'] = ''\n return config\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n raw_config = cfg_mgr.config\n llm_config = dict(raw_config.get('llm', {}))\n\n if raw_config.get('active_backend') == 'online' and raw_config.get('online'):\n online = raw_config.get('online', {})\n llm_config.update({\n 'provider': online.get('provider', llm_config.get('provider', 'custom')),\n 'model': online.get('model', llm_config.get('model', '')),\n 'api_base': online.get('api_base', llm_config.get('api_base', '')),\n 'api_key': online.get('api_key', llm_config.get('api_key', '')),\n })\n elif raw_config.get('active_backend') == 'ollama' and raw_config.get('ollama'):\n ollama = raw_config.get('ollama', {})\n llm_config.update({\n 'provider': 'ollama',\n 'model': ollama.get('model', llm_config.get('model', '')),\n 'api_base': ollama.get('api_base', llm_config.get('api_base', 'http://localhost:11434')),\n 'api_key': '',\n })\n\n return jsonify({\n 'config': _mask_api_key(llm_config),\n 'online_providers': _public_online_providers(),\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.json or {}\n config = get_config_manager()\n\n try:\n cfg = config.config\n llm = cfg.setdefault('llm', {})\n provider = (data.get('provider') or llm.get('provider') or 'ollama').strip()\n if provider not in ['ollama', 'openai', 'deepseek', 'siliconflow', 'moonshot', 'dashscope', 'zhipu', 'custom']:\n raise ValueError(f'Invalid provider: {provider}')\n\n if provider == 'ollama':\n api_base = (data.get('api_base') or 'http://localhost:11434').strip().rstrip('/')\n model = (data.get('model') or llm.get('model') or '').strip()\n llm.update({\n 'provider': 'ollama',\n 'model': model,\n 'api_base': api_base,\n 'api_key': '',\n })\n cfg['active_backend'] = 'ollama'\n cfg.setdefault('ollama', {}).update({\n 'model': model,\n 'api_base': api_base,\n })\n else:\n preset = ONLINE_PROVIDERS.get(provider, {})\n online = cfg.setdefault('online', {})\n api_base = (data.get('api_base') or preset.get('api_base') or online.get('api_base') or llm.get('api_base') or '').strip().rstrip('/')\n model = (data.get('model') or preset.get('default_model') or online.get('model') or llm.get('model') or '').strip()\n api_key = data.get('api_key')\n if api_key is None or api_key == '':\n api_key = online.get('api_key') or llm.get('api_key', '')\n else:\n api_key = api_key.strip()\n\n llm.update({\n 'provider': provider,\n 'model': model,\n 'api_base': api_base,\n 'api_key': api_key,\n })\n cfg['active_backend'] = 'online'\n online.update({\n 'provider': provider,\n 'api_base': api_base,\n 'api_key': api_key,\n 'model': model,\n })\n\n if 'temperature' in data:\n llm['temperature'] = data['temperature']\n if 'max_tokens' in data:\n llm['max_tokens'] = data['max_tokens']\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n cfg['first_run'] = False\n\n config.save_config()\n return jsonify({'message': 'Configuration updated successfully', 'config': _mask_api_key(llm)})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.json\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: provider, api_base, api_key。\"\"\"\n from config_manager import get_config_manager\n\n provider = request.args.get('provider', '').strip()\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n\n if not api_base and provider in ONLINE_PROVIDERS:\n api_base = ONLINE_PROVIDERS[provider].get('api_base', '')\n\n # 如果请求没带 api_key,使用已保存的 online/llm key\n if not api_key:\n cfg = get_config_manager()\n api_key = (\n cfg.config.get('online', {}).get('api_key')\n or cfg.config.get('llm', {}).get('api_key', '')\n )\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = []\n if isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n elif isinstance(data, list):\n models = [\n m.get('id') or m.get('name', '')\n for m in data\n if isinstance(m, dict)\n ]\n\n models = sorted([m for m in models if m])\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models})\n\n except _ure.HTTPError as e:\n msgs = {401: 'API Key 无效或已过期 (401)', 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)'}\n return jsonify({'error': msgs.get(e.code, f'HTTP {e.code}: {e.reason}'), 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n manager = get_config_manager()\n cfg = dict(manager.get_llm_config())\n body = request.get_json(silent=True) or {}\n if body:\n saved_online = manager.config.get('online', {})\n saved_llm = manager.config.get('llm', {})\n cfg.update({\n 'provider': body.get('provider', cfg.get('provider', 'ollama')),\n 'model': body.get('model', cfg.get('model', '')),\n 'api_base': body.get('api_base', cfg.get('api_base', '')),\n })\n if body.get('api_key'):\n cfg['api_key'] = body.get('api_key')\n else:\n cfg['api_key'] = saved_online.get('api_key') or saved_llm.get('api_key') or cfg.get('api_key', '')\n\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "4e0caf42daab295ef7c847f9fbd2c98b89993812": {"t": 1785763103.125807, "n": 10043, "h": "99df870b54b2d93d", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n llm_config = dict(cfg_mgr.config.get('llm', {}))\n\n # Hide API key for security\n if llm_config.get('api_key'):\n k = llm_config['api_key']\n llm_config['api_key_masked'] = '****' + k[-4:] if len(k) > 4 else '****'\n llm_config['api_key'] = '' # don't send raw key to browser\n\n return jsonify({\n 'config': llm_config,\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.json\n config = get_config_manager()\n\n try:\n if 'provider' in data:\n config.set_llm_provider(data['provider'])\n\n if 'model' in data:\n config.set_llm_model(data['model'])\n\n if 'api_key' in data and data['api_key']:\n config.set_api_key(data['api_key'])\n\n if 'api_base' in data:\n config.set_api_base(data['api_base'])\n\n if 'temperature' in data:\n config.config['llm']['temperature'] = data['temperature']\n config.save_config()\n\n if 'max_tokens' in data:\n config.config['llm']['max_tokens'] = data['max_tokens']\n config.save_config()\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n config.mark_first_run_complete()\n\n return jsonify({'message': 'Configuration updated successfully'})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.json\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/online/models', methods=['GET'])\ndef get_online_api_models():\n \"\"\"从在线 API 获取可用模型列表。支持 GET 参数: api_base, api_key (可选,优先用已保存的 key)\"\"\"\n from config_manager import get_config_manager\n\n api_base = request.args.get('api_base', '').strip()\n api_key = request.args.get('api_key', '').strip()\n\n # 如果请求没带 api_key,使用已保存的\n if not api_key:\n cfg = get_config_manager()\n api_key = cfg.config.get('llm', {}).get('api_key', '')\n\n if not api_base:\n return jsonify({'error': '请提供 api_base 参数', 'models': []}), 400\n if not api_key:\n return jsonify({'error': '请先填写并保存 API Key,或在参数中提供 api_key', 'models': []}), 400\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n url = api_base.rstrip('/') + '/models'\n try:\n req = _urq.Request(\n url,\n headers={\n 'Authorization': f'Bearer {api_key}',\n 'Content-Type': 'application/json',\n }\n )\n with _urq.urlopen(req, timeout=12) as resp:\n data = json.loads(resp.read().decode('utf-8'))\n\n models = []\n if isinstance(data.get('data'), list):\n models = [m['id'] for m in data['data'] if isinstance(m, dict) and m.get('id')]\n elif isinstance(data.get('models'), list):\n models = [m.get('id') or m.get('name', '') for m in data['models'] if isinstance(m, dict)]\n\n models = sorted([m for m in models if m])\n if not models:\n return jsonify({'error': '接口返回了空模型列表', 'models': []}), 502\n\n return jsonify({'models': models})\n\n except _ure.HTTPError as e:\n msgs = {401: 'API Key 无效或已过期 (401)', 403: '访问被拒绝,请检查权限 (403)',\n 404: '该平台不支持模型列表接口 (404)'}\n return jsonify({'error': msgs.get(e.code, f'HTTP {e.code}: {e.reason}'), 'models': []}), 502\n except (_ure.URLError, OSError) as e:\n return jsonify({'error': f'网络连接失败: {e.reason if hasattr(e, \"reason\") else str(e)}', 'models': []}), 502\n except Exception as e:\n return jsonify({'error': f'获取模型列表失败: {str(e)}', 'models': []}), 502\n\n\n@bp.route('/api/llm/test', methods=['POST'])\ndef test_llm_connection():\n \"\"\"轻量测试 LLM 连接:发送一条最短的 chat 消息,返回详细错误信息。\"\"\"\n from config_manager import get_config_manager\n\n cfg = get_config_manager().get_llm_config()\n provider = cfg.get('provider', 'ollama')\n model = cfg.get('model', '')\n api_base = cfg.get('api_base', 'http://localhost:11434')\n api_key = cfg.get('api_key', '')\n\n if not model:\n return jsonify({'ok': False, 'error': '未配置模型,请先保存配置'})\n\n import urllib.request as _urq\n import urllib.error as _ure\n\n # ── Ollama 分支 ─────────────────────────────────────────────────\n if provider == 'ollama':\n try:\n req = _urq.Request(f'{api_base.rstrip(\"/\")}/api/tags')\n with _urq.urlopen(req, timeout=5) as resp:\n ok = resp.status == 200\n if ok:\n return jsonify({'ok': True, 'message': f'Ollama 连接正常,当前模型: {model}'})\n return jsonify({'ok': False, 'error': 'Ollama 服务未响应,请运行 ollama serve'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'Ollama 连接失败: {str(e)}'})\n\n # ── 在线 API 分支 ────────────────────────────────────────────────\n if not api_key:\n return jsonify({'ok': False, 'error': '未配置 API Key,请填写后保存'})\n\n payload = json.dumps({\n 'model': model,\n 'messages': [{'role': 'user', 'content': 'hi'}],\n 'max_tokens': 5,\n 'temperature': 0,\n }).encode('utf-8')\n\n try:\n req = _urq.Request(\n f'{api_base.rstrip(\"/\")}/chat/completions',\n data=payload,\n headers={\n 'Content-Type': 'application/json',\n 'Authorization': f'Bearer {api_key}',\n },\n method='POST',\n )\n with _urq.urlopen(req, timeout=30) as resp:\n result = json.loads(resp.read().decode('utf-8'))\n\n if result.get('choices'):\n return jsonify({'ok': True, 'message': f'✓ {model} 响应正常'})\n return jsonify({'ok': False, 'error': '模型返回了空结果,请检查模型名称'})\n\n except _ure.HTTPError as e:\n body = ''\n try: body = e.read().decode('utf-8', errors='ignore')[:200]\n except Exception: pass\n msgs = {\n 401: f'API Key 无效或已过期 (401)',\n 403: f'访问被拒绝,请检查权限 (403)',\n 404: f'模型 \"{model}\" 不存在,请确认模型名称 (404)',\n 429: '请求频率超限,稍后再试 (429)',\n }\n err = msgs.get(e.code, f'HTTP {e.code}')\n if body: err += f' — {body}'\n return jsonify({'ok': False, 'error': err})\n except (_ure.URLError, OSError) as e:\n reason = e.reason if hasattr(e, 'reason') else str(e)\n if 'timed out' in str(reason).lower():\n return jsonify({'ok': False, 'error': '连接超时,请检查网络或 API 地址是否正确'})\n return jsonify({'ok': False, 'error': f'网络连接失败: {reason}'})\n except Exception as e:\n return jsonify({'ok': False, 'error': f'测试失败: {str(e)}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}, "55f2c6c1786312cbce126a819d884e7942ebce74": {"t": 1785763104.3573942, "n": 5016, "h": "2c18e189462468b8", "c": "\"\"\"LLM 配置路由\"\"\"\nfrom flask import Blueprint, request, jsonify\nimport sys\nimport json\nimport threading\nimport time as _time\nimport urllib.request as _ur\nfrom pathlib import Path\nfrom state import _pull_status\n\nbp = Blueprint('llm', __name__)\n\n\n@bp.route('/api/llm/config', methods=['GET'])\ndef llm_config_get():\n \"\"\"Get current LLM configuration (reads directly from config.json)\"\"\"\n from config_manager import LLMConfigManager\n cfg_mgr = LLMConfigManager()\n llm_config = dict(cfg_mgr.config.get('llm', {}))\n\n # Hide API key for security\n if llm_config.get('api_key'):\n k = llm_config['api_key']\n llm_config['api_key_masked'] = '****' + k[-4:] if len(k) > 4 else '****'\n llm_config['api_key'] = '' # don't send raw key to browser\n\n return jsonify({\n 'config': llm_config,\n 'first_run': cfg_mgr.is_first_run(),\n 'ollama_available': cfg_mgr.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/config', methods=['POST'])\ndef update_llm_config():\n \"\"\"Update LLM configuration\"\"\"\n from config_manager import get_config_manager\n data = request.json\n config = get_config_manager()\n\n try:\n if 'provider' in data:\n config.set_llm_provider(data['provider'])\n\n if 'model' in data:\n config.set_llm_model(data['model'])\n\n if 'api_key' in data and data['api_key']:\n config.set_api_key(data['api_key'])\n\n if 'api_base' in data:\n config.set_api_base(data['api_base'])\n\n if 'temperature' in data:\n config.config['llm']['temperature'] = data['temperature']\n config.save_config()\n\n if 'max_tokens' in data:\n config.config['llm']['max_tokens'] = data['max_tokens']\n config.save_config()\n\n # Mark first run complete if this is the first setup\n if config.is_first_run():\n config.mark_first_run_complete()\n\n return jsonify({'message': 'Configuration updated successfully'})\n except Exception as e:\n return jsonify({'error': str(e)}), 400\n\n\n@bp.route('/api/llm/ollama/models', methods=['GET'])\ndef get_ollama_models():\n \"\"\"Get available Ollama models\"\"\"\n from config_manager import get_config_manager\n config = get_config_manager()\n models = config.get_ollama_models()\n recommended = config.get_recommended_models().get('ollama', [])\n\n return jsonify({\n 'installed': models,\n 'recommended': recommended,\n 'ollama_available': config.check_ollama_available()\n })\n\n\n@bp.route('/api/llm/ollama/pull', methods=['POST'])\ndef pull_ollama_model():\n \"\"\"Pull an Ollama model and track progress.\"\"\"\n data = request.json\n model_name = data.get('model')\n\n if not model_name:\n return jsonify({'error': 'Model name required'}), 400\n\n _pull_status[model_name] = {'status': 'pulling', 'progress': 0, 'detail': '', 'error': ''}\n\n def pull_in_background():\n try:\n # Use Ollama streaming pull API to track progress\n url = 'http://localhost:11434/api/pull'\n payload = json.dumps({'name': model_name, 'stream': True}).encode()\n req = _ur.Request(url, data=payload, method='POST',\n headers={'Content-Type': 'application/json'})\n with _ur.urlopen(req, timeout=1800) as resp:\n for raw_line in resp:\n line = raw_line.decode().strip()\n if not line:\n continue\n try:\n obj = json.loads(line)\n except Exception:\n continue\n status_msg = obj.get('status', '')\n total = obj.get('total', 0)\n completed = obj.get('completed', 0)\n pct = int(completed * 100 / total) if total else 0\n detail = f\"{completed/1e9:.1f} GB / {total/1e9:.1f} GB\" if total else status_msg\n _pull_status[model_name] = {\n 'status': 'pulling', 'progress': pct,\n 'detail': detail, 'error': ''\n }\n _pull_status[model_name] = {'status': 'done', 'progress': 100, 'detail': '', 'error': ''}\n except Exception as ex:\n _pull_status[model_name] = {'status': 'error', 'progress': 0,\n 'detail': '', 'error': str(ex)}\n\n thread = threading.Thread(target=pull_in_background, daemon=True)\n thread.start()\n return jsonify({'message': f'Started pulling model: {model_name}'})\n\n\n@bp.route('/api/llm/ollama/pull/status', methods=['GET'])\ndef pull_ollama_status():\n \"\"\"Return current pull progress for a model.\"\"\"\n model_name = request.args.get('model', '')\n if not model_name:\n return jsonify({'error': 'model param required'}), 400\n info = _pull_status.get(model_name, {'status': 'unknown', 'progress': 0, 'detail': '', 'error': ''})\n return jsonify(info)\n"}}, "ONLINE_API_SETUP.md": {"5ef458cab746280eb97d2dfb27bd545c578d5400": {"t": 1785747354.8156664, "n": 5621, "h": "2cd419e3d01ee55c", "c": "# 在线 API 模型配置指南\n\n## 问题诊断\n\n你的大模型接入在线 API 不工作的原因已找到并已修复!\n\n### 原始问题\n- **旧代码**只支持 Ollama 本地模型\n- 当设置 Qwen/DeepSeek 的在线 API 时,系统会自动降级到规则引擎\n- 没有自动检测在线 API 的模型列表功能\n\n### 已修复项目\n✅ 扩展了 `llm_agent.py`,添加 `OpenAICompatibleClient` 支持 \n✅ 修改了 `build_agent_from_config()` 以支持在线 API \n✅ 添加了在线 API 模型列表自动检测功能 \n✅ 更新了 `IntentClassifier` 以正确调用在线 API \n✅ 提供了模型列表获取工具\n\n---\n\n## 配置方法\n\n### 方法 1:使用 Python API(推荐)\n\n```python\nfrom config_manager import LLMConfigManager\n\ncfg = LLMConfigManager()\n\n# 设置 DeepSeek\ncfg.set_llm_provider('deepseek')\ncfg.set_api_base('https://api.deepseek.com/v1')\ncfg.set_api_key('your-deepseek-api-key-here')\n\n# 自动检测可用模型\nmodels = cfg.get_online_api_models()\nif models:\n print(f\"可用模型: {models}\")\n # 选择第一个模型\n cfg.set_llm_model(models[0])\n```\n\n### 方法 2:直接编辑配置文件\n\n配置文件位置:`~/.seismicx/config.json`\n\n```json\n{\n \"llm\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\",\n \"api_base\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"your-api-key-here\",\n \"temperature\": 0.3,\n \"max_tokens\": 2000\n }\n}\n```\n\n---\n\n## 支持的在线 API 服务商\n\n### 🔷 DeepSeek\n- **官网**: https://www.deepseek.com/\n- **API 文档**: https://platform.deepseek.com/api-docs/\n- **API Base**: `https://api.deepseek.com/v1`\n- **推荐模型**:\n - `deepseek-v4-flash` (快速,适合实时应用)\n - `deepseek-v4-pro` (强大的推理)\n\n### 🟢 OpenAI\n- **官网**: https://openai.com/\n- **API 文档**: https://platform.openai.com/docs/\n- **API Base**: `https://api.openai.com/v1`\n- **推荐模型**:\n - `gpt-4o-mini` (经济,性价比高)\n - `gpt-4o` (最强)\n\n### 🔶 通义千问(SiliconFlow 代理)\n- **官网**: https://www.siliconflow.cn/\n- **API 文档**: https://docs.siliconflow.cn/\n- **API Base**: `https://api.siliconflow.cn/v1`\n- **推荐模型**:\n - `Qwen/Qwen2.5-7B-Instruct` (中文优化)\n - `Qwen/Qwen2.5-14B-Instruct` (更强)\n\n### 🌙 Moonshot / Kimi\n- **官网**: https://www.moonshot.cn/\n- **API 文档**: https://platform.moonshot.cn/docs/\n- **API Base**: `https://api.moonshot.cn/v1`\n- **推荐模型**:\n - `moonshot-v1-8k`\n\n### 🌩️ 阿里云 DashScope\n- **官网**: https://dashscope.aliyun.com/\n- **API Base**: `https://dashscope.aliyuncs.com/compatible-mode/v1`\n- **推荐模型**:\n - `qwen-turbo` (快速)\n - `qwen-plus` (均衡)\n\n### 💠 智谱 AI\n- **官网**: https://www.zhipuai.cn/\n- **API Base**: `https://open.bigmodel.cn/api/paas/v4`\n- **推荐模型**:\n - `glm-4-flash` (免费)\n - `glm-4` (强大)\n\n---\n\n## 快速开始\n\n### 1️⃣ 获取 API Key\n\n以 DeepSeek 为例:\n1. 访问 https://platform.deepseek.com/\n2. 创建账户并充值\n3. 在 API Keys 页面创建新的 API Key\n4. 复制 API Key\n\n### 2️⃣ 检测可用模型\n\n运行工具脚本:\n\n```bash\n# 检测当前配置\npython test_online_api_models.py\n\n# 检测特定 API\npython test_online_api_models.py deepseek sk-xxxxxxxxxxxxxxxx\npython test_online_api_models.py qwen sk-xxxxxxxxxxxxxxxx\n```\n\n### 3️⃣ 配置系统\n\n```python\nfrom config_manager import LLMConfigManager\n\ncfg = LLMConfigManager()\ncfg.set_llm_provider('deepseek')\ncfg.set_api_key('sk-your-key-here')\n\n# 自动获取并设置第一个可用模型\nmodels = cfg.get_online_api_models()\nif models:\n cfg.set_llm_model(models[0])\n print(f\"✓ 已配置为: {models[0]}\")\n```\n\n### 4️⃣ 测试连接\n\n```python\nfrom conversational_agent import ConversationalAgent\n\nagent = ConversationalAgent()\nif agent.llm_agent:\n print(\"✓ 在线 API 已连接,支持 AI 驱动的对话\")\n result = agent.process_message(\"你好,我想检测地震数据\")\n print(result['response'])\nelse:\n print(\"✗ 在线 API 连接失败,使用规则引擎模式\")\n```\n\n---\n\n## 常见问题\n\n### Q: 为什么设置完 API 后还是不工作?\n\n**A**: 请检查:\n1. **API Key 是否正确**:在代码中测试 API Key\n2. **网络连接**:确保能访问 API 端点\n3. **模型名称**:确保模型名称与 API 返回的完全一致\n4. **API Base URL**:检查是否包含 `/v1` 后缀(某些 API 需要)\n\n### Q: 如何查看当前是否使用了在线 API?\n\n**A**: \n```python\nfrom conversational_agent import ConversationalAgent\nagent = ConversationalAgent()\nprint(f\"使用 LLM 路径: {agent.llm_agent is not None}\")\nprint(f\"错误信息: {agent.llm_error}\")\n```\n\n### Q: 为什么 API 调用很慢?\n\n**A**: \n- 在线 API 响应时间取决于网络和服务器\n- 建议使用 `temperature=0.3` 以下的低温值以加快响应\n- 某些模型比其他模型更快(如 `-flash` / `-mini` 版本)\n\n### Q: 可以同时使用多个 API 吗?\n\n**A**: 当前配置支持一个活跃 API。如果需要多个,可以:\n1. 在不同的配置文件中切换\n2. 在代码中动态创建多个 `OpenAICompatibleClient` 实例\n\n---\n\n## 深度集成\n\n### 在自定义脚本中使用\n\n```python\nfrom llm_agent import OpenAICompatibleClient, OllamaToolAgent\nfrom conversational_agent import SkillExecutor, ConversationContext\nfrom config_manager import LLMConfigManager\n\n# 获取配置\ncfg = LLMConfigManager()\nllm_cfg = cfg.get_llm_config()\n\n# 创建 OpenAI-compatible 客户端\nclient = OpenAICompatibleClient(\n api_base=llm_cfg['api_base'],\n model=llm_cfg['model'],\n api_key=llm_cfg['api_key'],\n)\n\n# 创建 Agent\nexecutor = SkillExecutor()\ncontext = ConversationContext()\nagent = OllamaToolAgent(\n skill_executor=executor,\n context=context,\n client=client,\n)\n\n# 使用 Agent\nresponse = agent.process_message(\"检测数据目录下的所有地震波形\")\nprint(response['response'])\n```\n\n### 获取在线 API 的模型列表\n\n```python\nfrom llm_agent import OpenAICompatibleClient\n\n# 获取 DeepSeek 的所有模型\nmodels = OpenAICompatibleClient.list_models(\n api_base='https://api.deepseek.com/v1',\n api_key='your-api-key'\n)\nprint(f\"可用模型: {models}\")\n```\n\n---\n\n## 技术细节\n\n### 支持的 API 格式\n系统支持 **OpenAI-compatible** API 格式,包括:\n- `/v1/chat/completions` 端点\n- `/v1/models` 或 `/models` 端点\n- `Bearer {api_key}` 认证方式\n\n### 响应格式兼容\n自动处理的响应格式:\n```json\n{\n \"data\": [\n {\"id\": \"model-name\"},\n ...\n ]\n}\n```\n\n或\n\n```json\n{\n \"models\": [\n {\"name\": \"model-name\"},\n ...\n ]\n}\n```\n\n### 工具调用支持\n在线 API 支持与 Ollama 相同的 tool-calling 格式,自动转换为 OpenAI 格式。\n\n---\n\n## 性能建议\n\n| 场景 | 推荐模型 | API |\n|------|---------|-----|\n| **快速响应** | deepseek-v4-flash / gpt-4o-mini | DeepSeek / OpenAI |\n| **中文优化** | Qwen2.5-7B-Instruct | SiliconFlow |\n| **最高质量** | deepseek-v4-pro / gpt-4o | DeepSeek / OpenAI |\n| **成本最低** | glm-4-flash | 智谱 AI |\n\n---\n\n## 更新日志\n\n### v2025.04.28\n✅ 添加 OpenAI-compatible API 支持 \n✅ 自动模型列表检测 \n✅ 多 provider 支持(DeepSeek、OpenAI、通义千问等) \n✅ 工具调用兼容性 \n\n---\n\n**需要帮助?** 查看 `pnsn/LLM_SETUP_GUIDE.md` 了解更多详情。\n"}, "4e0caf42daab295ef7c847f9fbd2c98b89993812": {"t": 1785763087.5777206, "n": 5621, "h": "2cd419e3d01ee55c", "c": "# 在线 API 模型配置指南\n\n## 问题诊断\n\n你的大模型接入在线 API 不工作的原因已找到并已修复!\n\n### 原始问题\n- **旧代码**只支持 Ollama 本地模型\n- 当设置 Qwen/DeepSeek 的在线 API 时,系统会自动降级到规则引擎\n- 没有自动检测在线 API 的模型列表功能\n\n### 已修复项目\n✅ 扩展了 `llm_agent.py`,添加 `OpenAICompatibleClient` 支持 \n✅ 修改了 `build_agent_from_config()` 以支持在线 API \n✅ 添加了在线 API 模型列表自动检测功能 \n✅ 更新了 `IntentClassifier` 以正确调用在线 API \n✅ 提供了模型列表获取工具\n\n---\n\n## 配置方法\n\n### 方法 1:使用 Python API(推荐)\n\n```python\nfrom config_manager import LLMConfigManager\n\ncfg = LLMConfigManager()\n\n# 设置 DeepSeek\ncfg.set_llm_provider('deepseek')\ncfg.set_api_base('https://api.deepseek.com/v1')\ncfg.set_api_key('your-deepseek-api-key-here')\n\n# 自动检测可用模型\nmodels = cfg.get_online_api_models()\nif models:\n print(f\"可用模型: {models}\")\n # 选择第一个模型\n cfg.set_llm_model(models[0])\n```\n\n### 方法 2:直接编辑配置文件\n\n配置文件位置:`~/.seismicx/config.json`\n\n```json\n{\n \"llm\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\",\n \"api_base\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"your-api-key-here\",\n \"temperature\": 0.3,\n \"max_tokens\": 2000\n }\n}\n```\n\n---\n\n## 支持的在线 API 服务商\n\n### 🔷 DeepSeek\n- **官网**: https://www.deepseek.com/\n- **API 文档**: https://platform.deepseek.com/api-docs/\n- **API Base**: `https://api.deepseek.com/v1`\n- **推荐模型**:\n - `deepseek-v4-flash` (快速,适合实时应用)\n - `deepseek-v4-pro` (强大的推理)\n\n### 🟢 OpenAI\n- **官网**: https://openai.com/\n- **API 文档**: https://platform.openai.com/docs/\n- **API Base**: `https://api.openai.com/v1`\n- **推荐模型**:\n - `gpt-4o-mini` (经济,性价比高)\n - `gpt-4o` (最强)\n\n### 🔶 通义千问(SiliconFlow 代理)\n- **官网**: https://www.siliconflow.cn/\n- **API 文档**: https://docs.siliconflow.cn/\n- **API Base**: `https://api.siliconflow.cn/v1`\n- **推荐模型**:\n - `Qwen/Qwen2.5-7B-Instruct` (中文优化)\n - `Qwen/Qwen2.5-14B-Instruct` (更强)\n\n### 🌙 Moonshot / Kimi\n- **官网**: https://www.moonshot.cn/\n- **API 文档**: https://platform.moonshot.cn/docs/\n- **API Base**: `https://api.moonshot.cn/v1`\n- **推荐模型**:\n - `moonshot-v1-8k`\n\n### 🌩️ 阿里云 DashScope\n- **官网**: https://dashscope.aliyun.com/\n- **API Base**: `https://dashscope.aliyuncs.com/compatible-mode/v1`\n- **推荐模型**:\n - `qwen-turbo` (快速)\n - `qwen-plus` (均衡)\n\n### 💠 智谱 AI\n- **官网**: https://www.zhipuai.cn/\n- **API Base**: `https://open.bigmodel.cn/api/paas/v4`\n- **推荐模型**:\n - `glm-4-flash` (免费)\n - `glm-4` (强大)\n\n---\n\n## 快速开始\n\n### 1️⃣ 获取 API Key\n\n以 DeepSeek 为例:\n1. 访问 https://platform.deepseek.com/\n2. 创建账户并充值\n3. 在 API Keys 页面创建新的 API Key\n4. 复制 API Key\n\n### 2️⃣ 检测可用模型\n\n运行工具脚本:\n\n```bash\n# 检测当前配置\npython test_online_api_models.py\n\n# 检测特定 API\npython test_online_api_models.py deepseek sk-xxxxxxxxxxxxxxxx\npython test_online_api_models.py qwen sk-xxxxxxxxxxxxxxxx\n```\n\n### 3️⃣ 配置系统\n\n```python\nfrom config_manager import LLMConfigManager\n\ncfg = LLMConfigManager()\ncfg.set_llm_provider('deepseek')\ncfg.set_api_key('sk-your-key-here')\n\n# 自动获取并设置第一个可用模型\nmodels = cfg.get_online_api_models()\nif models:\n cfg.set_llm_model(models[0])\n print(f\"✓ 已配置为: {models[0]}\")\n```\n\n### 4️⃣ 测试连接\n\n```python\nfrom conversational_agent import ConversationalAgent\n\nagent = ConversationalAgent()\nif agent.llm_agent:\n print(\"✓ 在线 API 已连接,支持 AI 驱动的对话\")\n result = agent.process_message(\"你好,我想检测地震数据\")\n print(result['response'])\nelse:\n print(\"✗ 在线 API 连接失败,使用规则引擎模式\")\n```\n\n---\n\n## 常见问题\n\n### Q: 为什么设置完 API 后还是不工作?\n\n**A**: 请检查:\n1. **API Key 是否正确**:在代码中测试 API Key\n2. **网络连接**:确保能访问 API 端点\n3. **模型名称**:确保模型名称与 API 返回的完全一致\n4. **API Base URL**:检查是否包含 `/v1` 后缀(某些 API 需要)\n\n### Q: 如何查看当前是否使用了在线 API?\n\n**A**: \n```python\nfrom conversational_agent import ConversationalAgent\nagent = ConversationalAgent()\nprint(f\"使用 LLM 路径: {agent.llm_agent is not None}\")\nprint(f\"错误信息: {agent.llm_error}\")\n```\n\n### Q: 为什么 API 调用很慢?\n\n**A**: \n- 在线 API 响应时间取决于网络和服务器\n- 建议使用 `temperature=0.3` 以下的低温值以加快响应\n- 某些模型比其他模型更快(如 `-flash` / `-mini` 版本)\n\n### Q: 可以同时使用多个 API 吗?\n\n**A**: 当前配置支持一个活跃 API。如果需要多个,可以:\n1. 在不同的配置文件中切换\n2. 在代码中动态创建多个 `OpenAICompatibleClient` 实例\n\n---\n\n## 深度集成\n\n### 在自定义脚本中使用\n\n```python\nfrom llm_agent import OpenAICompatibleClient, OllamaToolAgent\nfrom conversational_agent import SkillExecutor, ConversationContext\nfrom config_manager import LLMConfigManager\n\n# 获取配置\ncfg = LLMConfigManager()\nllm_cfg = cfg.get_llm_config()\n\n# 创建 OpenAI-compatible 客户端\nclient = OpenAICompatibleClient(\n api_base=llm_cfg['api_base'],\n model=llm_cfg['model'],\n api_key=llm_cfg['api_key'],\n)\n\n# 创建 Agent\nexecutor = SkillExecutor()\ncontext = ConversationContext()\nagent = OllamaToolAgent(\n skill_executor=executor,\n context=context,\n client=client,\n)\n\n# 使用 Agent\nresponse = agent.process_message(\"检测数据目录下的所有地震波形\")\nprint(response['response'])\n```\n\n### 获取在线 API 的模型列表\n\n```python\nfrom llm_agent import OpenAICompatibleClient\n\n# 获取 DeepSeek 的所有模型\nmodels = OpenAICompatibleClient.list_models(\n api_base='https://api.deepseek.com/v1',\n api_key='your-api-key'\n)\nprint(f\"可用模型: {models}\")\n```\n\n---\n\n## 技术细节\n\n### 支持的 API 格式\n系统支持 **OpenAI-compatible** API 格式,包括:\n- `/v1/chat/completions` 端点\n- `/v1/models` 或 `/models` 端点\n- `Bearer {api_key}` 认证方式\n\n### 响应格式兼容\n自动处理的响应格式:\n```json\n{\n \"data\": [\n {\"id\": \"model-name\"},\n ...\n ]\n}\n```\n\n或\n\n```json\n{\n \"models\": [\n {\"name\": \"model-name\"},\n ...\n ]\n}\n```\n\n### 工具调用支持\n在线 API 支持与 Ollama 相同的 tool-calling 格式,自动转换为 OpenAI 格式。\n\n---\n\n## 性能建议\n\n| 场景 | 推荐模型 | API |\n|------|---------|-----|\n| **快速响应** | deepseek-v4-flash / gpt-4o-mini | DeepSeek / OpenAI |\n| **中文优化** | Qwen2.5-7B-Instruct | SiliconFlow |\n| **最高质量** | deepseek-v4-pro / gpt-4o | DeepSeek / OpenAI |\n| **成本最低** | glm-4-flash | 智谱 AI |\n\n---\n\n## 更新日志\n\n### v2025.04.28\n✅ 添加 OpenAI-compatible API 支持 \n✅ 自动模型列表检测 \n✅ 多 provider 支持(DeepSeek、OpenAI、通义千问等) \n✅ 工具调用兼容性 \n\n---\n\n**需要帮助?** 查看 `pnsn/LLM_SETUP_GUIDE.md` 了解更多详情。\n"}}}}