Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/aceyuan361_insight-aitest.json
T

1 line
46 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "aceyuan361/insight-aitest", "files": {"insight_aitest/platform/services/llm/config.py": {"88542831cfdb651ba9484aa1ef3571ebd9a73df0": {"t": 1785746866.3687778, "n": 8447, "h": "9121f00911fc912b", "c": "# -*- coding: utf-8 -*-\n\"\"\"LLM 配置(平台共享服务)。加载优先级:环境变量 > 配置文件 > 默认值。\n\n从 ai 模块上提(原 AIConfig → LLMConfig),ai 和 testcase 模块共用。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nfrom dataclasses import asdict, dataclass, field\n\n\n@dataclass\nclass LLMConfig:\n # LLM(OpenAI 兼容)\n llm_base_url: str = \"https://api.openai.com/v1\"\n llm_api_key: str = \"\"\n chat_model: str = \"gpt-4o-mini\"\n vision_model: str = \"\" # 视觉模型(Midscene 用),空则回退 chat_model\n llm_timeout: int = 60\n\n # ===== 向量检索(子分类:默认关,个人版文档量小 ROI 低)=====\n # 关闭后:文档仍可上传/解析/分块/存储/查阅,但 AI 对话不做向量召回(走纯 LLM),用例生成器无参考资料。\n # 开启后:需要配置 embedding 端点(chat 端点无 embedding 模型时,可单独指定 embed_base_url/embed_api_key)。\n vector_enabled: bool = False # 默认关;需向量召回(RAG)时开启\n embed_base_url: str = \"\" # 空 = 回退 llm_base_url\n embed_api_key: str = \"\" # 空 = 回退 llm_api_key\n embed_model: str = \"text-embedding-3-small\"\n embed_dim: int = 1536\n embed_batch_size: int = 64\n\n # 知识库管线(与向量检索正交:分块在 vector 关闭时仍用于文档存储)\n chunk_size: int = 500\n chunk_overlap: int = 80\n chunk_strategy: str = (\n \"recursive\" # \"recursive\"(字符)| \"semantic\"(embedding 相似度,需 vector_enabled)\n )\n semantic_breakpoint: float = 2.58 # 语义断点 std 倍数(仅 semantic 生效)\n top_k: int = 4\n min_score: float = 0.1\n max_upload_mb: int = 20\n max_chunks_per_doc: int = 5000\n\n # Rerank(LLM-as-reranker:向量召回 N 候选 → LLM 打分 → 重排取 top_k)\n rerank_enabled: bool = False # 默认关,避免拖慢现有 RAG;按需开启\n rerank_fetch_k: int = 12 # rerank 前的候选数(应 ≥ top_k)\n\n # OCR(多模态:用 vision model 读图,扫描件 PDF / 图片文档抽取文字)\n ocr_enabled: bool = True # 默认开(需 vision_model 配置;关则扫描件/图片报错引导)\n\n # 对话\n history_turns: int = 6\n\n # 存储(~ 由 expand_paths 展开为绝对路径)\n db_path: str = \"~/.insight_eye/kb.db\"\n docs_dir: str = \"~/.insight_eye/ai_docs\"\n config_file: str = \"~/.insight_eye/llm_config.json\"\n\n # ReAct 大脑层配置(Agent Brain 2.0):嵌套 dict,由 load_config 自动从\n # llm_config.json 的 \"react\" 节加载(load_config 的 hasattr 循环已覆盖)。\n # 形如 {\"enabled\": true, \"max_iterations\": 8, \"budget\": {\"retry\": 2, \"fix\": 2, \"consecutive_fail\": 3}}\n react: dict = field(default_factory=dict)\n\n # ===== UI 自动化视觉模型独立配置 =====\n # UI 自动化需要多模态视觉模型(如 gpt-4o),与全局对话模型(如 deepseek-chat)不同。\n # 此字段允许用户单独配置 UI 专用模型,留空则回退到全局 vision_model → chat_model。\n # 形如 {\"base_url\": \"\", \"api_key\": \"\", \"model\": \"\"}\n ui_vision_config: dict = field(default_factory=dict)\n\n # ===== 多 Provider 管理(Cursor 风格灵活切换)=====\n # providers 是用户保存的所有 Provider 配置列表;active_provider_id 指向当前生效的那一个。\n # 每条 provider 形如:\n # {\"id\": \"p1\", \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n # \"api_key\": \"sk-...\", \"chat_model\": \"deepseek-chat\", \"vision_model\": \"deepseek-vision\"}\n # 当 active_provider 存在时,其 base_url/api_key/chat_model/vision_model 投影到上面的扁平字段,\n # 保证旧的 LLMClient/LLMConfig 读取路径无感知(向后兼容)。\n providers: list = field(default_factory=list)\n active_provider_id: str = \"\"\n\n @property\n def api_key_set(self) -> bool:\n return bool(self.llm_api_key)\n\n def react_config(self):\n \"\"\"把 self.react(原始 dict)物化为 ReActConfig(懒导入避免 platform → ai 循环依赖)。\"\"\"\n from insight_aitest.modules.ai.backend.agent.reactor import ReActConfig\n\n return ReActConfig.from_dict(self.react)\n\n def expand_paths(self) -> \"LLMConfig\":\n self.db_path = os.path.expanduser(self.db_path)\n self.docs_dir = os.path.expanduser(self.docs_dir)\n self.config_file = os.path.expanduser(self.config_file)\n return self\n\n\n# 环境变量名映射(INSIGHT_EYE_AI_<UPPER_FIELD>)—— 保留 AI 前缀兼容现有部署\n_ENV_KEYS = {\n \"llm_base_url\": \"INSIGHT_EYE_AI_LLM_BASE_URL\",\n \"llm_api_key\": \"INSIGHT_EYE_AI_LLM_API_KEY\",\n \"chat_model\": \"INSIGHT_EYE_AI_CHAT_MODEL\",\n \"vision_model\": \"INSIGHT_EYE_AI_VISION_MODEL\",\n \"llm_timeout\": \"INSIGHT_EYE_AI_LLM_TIMEOUT\",\n \"embed_model\": \"INSIGHT_EYE_AI_EMBED_MODEL\",\n \"embed_dim\": \"INSIGHT_EYE_AI_EMBED_DIM\",\n \"embed_batch_size\": \"INSIGHT_EYE_AI_EMBED_BATCH_SIZE\",\n \"chunk_size\": \"INSIGHT_EYE_AI_CHUNK_SIZE\",\n \"chunk_overlap\": \"INSIGHT_EYE_AI_CHUNK_OVERLAP\",\n \"chunk_strategy\": \"INSIGHT_EYE_AI_CHUNK_STRATEGY\",\n \"semantic_breakpoint\": \"INSIGHT_EYE_AI_SEMANTIC_BREAKPOINT\",\n \"top_k\": \"INSIGHT_EYE_AI_TOP_K\",\n \"min_score\": \"INSIGHT_EYE_AI_MIN_SCORE\",\n \"max_upload_mb\": \"INSIGHT_EYE_AI_MAX_UPLOAD_MB\",\n \"rerank_enabled\": \"INSIGHT_EYE_AI_RERANK_ENABLED\",\n \"rerank_fetch_k\": \"INSIGHT_EYE_AI_RERANK_FETCH_K\",\n \"ocr_enabled\": \"INSIGHT_EYE_AI_OCR_ENABLED\",\n \"vector_enabled\": \"INSIGHT_EYE_AI_VECTOR_ENABLED\",\n \"embed_base_url\": \"INSIGHT_EYE_AI_EMBED_BASE_URL\",\n \"embed_api_key\": \"INSIGHT_EYE_AI_EMBED_API_KEY\",\n}\n\n# 需要转 int 的字段\n_INT_FIELDS = {\n \"llm_timeout\",\n \"embed_dim\",\n \"embed_batch_size\",\n \"chunk_size\",\n \"chunk_overlap\",\n \"top_k\",\n \"max_upload_mb\",\n \"max_chunks_per_doc\",\n \"rerank_fetch_k\",\n}\n# 需要转 float 的字段\n_FLOAT_FIELDS = {\"min_score\", \"semantic_breakpoint\"}\n\n# 需要转 bool 的字段(\"1\"/\"true\"/\"yes\"/\"on\" → True,其余 False)\n_BOOL_FIELDS = {\"rerank_enabled\", \"ocr_enabled\", \"vector_enabled\"}\n\n\ndef get_active_provider(cfg: LLMConfig) -> dict | None:\n \"\"\"返回当前生效的 Provider dict(按 active_provider_id 查找);无则 None。\"\"\"\n if not cfg.providers or not cfg.active_provider_id:\n return None\n for p in cfg.providers:\n if isinstance(p, dict) and p.get(\"id\") == cfg.active_provider_id:\n return p\n return None\n\n\ndef apply_provider(cfg: LLMConfig, provider_id: str | None = None) -> LLMConfig:\n \"\"\"把指定 Provider(默认 active_provider_id)的 base_url/api_key/chat_model/vision_model\n 投影到扁平字段。切换/加载时调用,保证旧的读取路径无感知。\n\n provider_id=None 时用 cfg.active_provider_id;找不到则不动扁平字段(保持原值)。\n \"\"\"\n pid = provider_id or cfg.active_provider_id\n if not pid:\n return cfg\n target = None\n for p in cfg.providers:\n if isinstance(p, dict) and p.get(\"id\") == pid:\n target = p\n break\n if not target:\n return cfg\n cfg.active_provider_id = pid\n # 投影:Provider 字段 → 扁平字段(只覆盖非空字段,空则保留原值以兼容 vision_model 等可选字段)\n if target.get(\"base_url\"):\n cfg.llm_base_url = target[\"base_url\"]\n if target.get(\"api_key\"):\n cfg.llm_api_key = target[\"api_key\"]\n if target.get(\"chat_model\"):\n cfg.chat_model = target[\"chat_model\"]\n # vision_model 允许显式置空(用空串覆盖,表示\"回退 chat_model\")\n if \"vision_model\" in target:\n cfg.vision_model = target.get(\"vision_model\") or \"\"\n return cfg\n\n\ndef load_config(config_file: str | None = None) -> LLMConfig:\n \"\"\"按 默认值 → 配置文件 → 环境变量 的顺序叠加(后者覆盖前者)。\"\"\"\n cfg = LLMConfig()\n\n # 配置文件\n path = config_file or cfg.config_file\n path = os.path.expanduser(path)\n # 兼容旧文件名:llm_config.json 不存在时回退读 ai_config.json(上提前的文件名)\n if not config_file and not os.path.exists(path):\n legacy = os.path.join(os.path.dirname(path), \"ai_config.json\")\n if os.path.exists(legacy):\n path = legacy\n file_data: dict = {}\n if os.path.exists(path):\n try:\n with open(path, \"r\", encoding=\"utf-8\") as f:\n file_data = json.load(f)\n except (json.JSONDecodeError, OSError):\n file_data = {}\n for k, v in file_data.items():\n if hasattr(cfg, k):\n setattr(cfg, k, v)\n\n # Provider 投影(在 env 覆盖前):把 active provider 的字段投影到扁平字段。\n # 这样 env 仍是最高优先级(无头部署强制锁定),provider 仅在 env 未设时生效。\n if cfg.providers and cfg.active_provider_id:\n apply_provider(cfg)\n\n # 环境变量(最高优先级)\n for field_name, env_name in _ENV_KEYS.items():\n val = os.getenv(env_name)\n if val is None:\n continue\n if field_name in _INT_FIELDS:\n val = int(val)\n elif field_name in _FLOAT_FIELDS:\n val = float(val)\n elif field_name in _BOOL_FIELDS:\n val = str(val).strip().lower() in (\"1\", \"true\", \"yes\", \"on\")\n setattr(cfg, field_name, val)\n\n return cfg.expand_paths()\n\n\ndef save_config(cfg: LLMConfig, config_file: str | None = None) -> None:\n \"\"\"把配置写回文件(PUT /config 用)。db_path/docs_dir/config_file 不写回(运行期展开值)。\"\"\"\n path = config_file or cfg.config_file\n path = os.path.expanduser(path)\n os.makedirs(os.path.dirname(path), exist_ok=True)\n data = asdict(cfg)\n data.pop(\"db_path\", None)\n data.pop(\"docs_dir\", None)\n data.pop(\"config_file\", None)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n json.dump(data, f, ensure_ascii=False, indent=2)\n\n\n# 向后兼容别名:ai 模块旧代码以 AIConfig 名字引用,保留无需改调用点\nAIConfig = LLMConfig\n"}, "aba2a8f467caacc25b2ea5d7472ce6d6a645adb8": {"t": 1785768242.8463485, "n": 8447, "h": "9121f00911fc912b", "c": "# -*- coding: utf-8 -*-\n\"\"\"LLM 配置(平台共享服务)。加载优先级:环境变量 > 配置文件 > 默认值。\n\n从 ai 模块上提(原 AIConfig → LLMConfig),ai 和 testcase 模块共用。\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nfrom dataclasses import asdict, dataclass, field\n\n\n@dataclass\nclass LLMConfig:\n # LLM(OpenAI 兼容)\n llm_base_url: str = \"https://api.openai.com/v1\"\n llm_api_key: str = \"\"\n chat_model: str = \"gpt-4o-mini\"\n vision_model: str = \"\" # 视觉模型(Midscene 用),空则回退 chat_model\n llm_timeout: int = 60\n\n # ===== 向量检索(子分类:默认关,个人版文档量小 ROI 低)=====\n # 关闭后:文档仍可上传/解析/分块/存储/查阅,但 AI 对话不做向量召回(走纯 LLM),用例生成器无参考资料。\n # 开启后:需要配置 embedding 端点(chat 端点无 embedding 模型时,可单独指定 embed_base_url/embed_api_key)。\n vector_enabled: bool = False # 默认关;需向量召回(RAG)时开启\n embed_base_url: str = \"\" # 空 = 回退 llm_base_url\n embed_api_key: str = \"\" # 空 = 回退 llm_api_key\n embed_model: str = \"text-embedding-3-small\"\n embed_dim: int = 1536\n embed_batch_size: int = 64\n\n # 知识库管线(与向量检索正交:分块在 vector 关闭时仍用于文档存储)\n chunk_size: int = 500\n chunk_overlap: int = 80\n chunk_strategy: str = (\n \"recursive\" # \"recursive\"(字符)| \"semantic\"(embedding 相似度,需 vector_enabled)\n )\n semantic_breakpoint: float = 2.58 # 语义断点 std 倍数(仅 semantic 生效)\n top_k: int = 4\n min_score: float = 0.1\n max_upload_mb: int = 20\n max_chunks_per_doc: int = 5000\n\n # Rerank(LLM-as-reranker:向量召回 N 候选 → LLM 打分 → 重排取 top_k)\n rerank_enabled: bool = False # 默认关,避免拖慢现有 RAG;按需开启\n rerank_fetch_k: int = 12 # rerank 前的候选数(应 ≥ top_k)\n\n # OCR(多模态:用 vision model 读图,扫描件 PDF / 图片文档抽取文字)\n ocr_enabled: bool = True # 默认开(需 vision_model 配置;关则扫描件/图片报错引导)\n\n # 对话\n history_turns: int = 6\n\n # 存储(~ 由 expand_paths 展开为绝对路径)\n db_path: str = \"~/.insight_eye/kb.db\"\n docs_dir: str = \"~/.insight_eye/ai_docs\"\n config_file: str = \"~/.insight_eye/llm_config.json\"\n\n # ReAct 大脑层配置(Agent Brain 2.0):嵌套 dict,由 load_config 自动从\n # llm_config.json 的 \"react\" 节加载(load_config 的 hasattr 循环已覆盖)。\n # 形如 {\"enabled\": true, \"max_iterations\": 8, \"budget\": {\"retry\": 2, \"fix\": 2, \"consecutive_fail\": 3}}\n react: dict = field(default_factory=dict)\n\n # ===== UI 自动化视觉模型独立配置 =====\n # UI 自动化需要多模态视觉模型(如 gpt-4o),与全局对话模型(如 deepseek-chat)不同。\n # 此字段允许用户单独配置 UI 专用模型,留空则回退到全局 vision_model → chat_model。\n # 形如 {\"base_url\": \"\", \"api_key\": \"\", \"model\": \"\"}\n ui_vision_config: dict = field(default_factory=dict)\n\n # ===== 多 Provider 管理(Cursor 风格灵活切换)=====\n # providers 是用户保存的所有 Provider 配置列表;active_provider_id 指向当前生效的那一个。\n # 每条 provider 形如:\n # {\"id\": \"p1\", \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n # \"api_key\": \"sk-...\", \"chat_model\": \"deepseek-chat\", \"vision_model\": \"deepseek-vision\"}\n # 当 active_provider 存在时,其 base_url/api_key/chat_model/vision_model 投影到上面的扁平字段,\n # 保证旧的 LLMClient/LLMConfig 读取路径无感知(向后兼容)。\n providers: list = field(default_factory=list)\n active_provider_id: str = \"\"\n\n @property\n def api_key_set(self) -> bool:\n return bool(self.llm_api_key)\n\n def react_config(self):\n \"\"\"把 self.react(原始 dict)物化为 ReActConfig(懒导入避免 platform → ai 循环依赖)。\"\"\"\n from insight_aitest.modules.ai.backend.agent.reactor import ReActConfig\n\n return ReActConfig.from_dict(self.react)\n\n def expand_paths(self) -> \"LLMConfig\":\n self.db_path = os.path.expanduser(self.db_path)\n self.docs_dir = os.path.expanduser(self.docs_dir)\n self.config_file = os.path.expanduser(self.config_file)\n return self\n\n\n# 环境变量名映射(INSIGHT_EYE_AI_<UPPER_FIELD>)—— 保留 AI 前缀兼容现有部署\n_ENV_KEYS = {\n \"llm_base_url\": \"INSIGHT_EYE_AI_LLM_BASE_URL\",\n \"llm_api_key\": \"INSIGHT_EYE_AI_LLM_API_KEY\",\n \"chat_model\": \"INSIGHT_EYE_AI_CHAT_MODEL\",\n \"vision_model\": \"INSIGHT_EYE_AI_VISION_MODEL\",\n \"llm_timeout\": \"INSIGHT_EYE_AI_LLM_TIMEOUT\",\n \"embed_model\": \"INSIGHT_EYE_AI_EMBED_MODEL\",\n \"embed_dim\": \"INSIGHT_EYE_AI_EMBED_DIM\",\n \"embed_batch_size\": \"INSIGHT_EYE_AI_EMBED_BATCH_SIZE\",\n \"chunk_size\": \"INSIGHT_EYE_AI_CHUNK_SIZE\",\n \"chunk_overlap\": \"INSIGHT_EYE_AI_CHUNK_OVERLAP\",\n \"chunk_strategy\": \"INSIGHT_EYE_AI_CHUNK_STRATEGY\",\n \"semantic_breakpoint\": \"INSIGHT_EYE_AI_SEMANTIC_BREAKPOINT\",\n \"top_k\": \"INSIGHT_EYE_AI_TOP_K\",\n \"min_score\": \"INSIGHT_EYE_AI_MIN_SCORE\",\n \"max_upload_mb\": \"INSIGHT_EYE_AI_MAX_UPLOAD_MB\",\n \"rerank_enabled\": \"INSIGHT_EYE_AI_RERANK_ENABLED\",\n \"rerank_fetch_k\": \"INSIGHT_EYE_AI_RERANK_FETCH_K\",\n \"ocr_enabled\": \"INSIGHT_EYE_AI_OCR_ENABLED\",\n \"vector_enabled\": \"INSIGHT_EYE_AI_VECTOR_ENABLED\",\n \"embed_base_url\": \"INSIGHT_EYE_AI_EMBED_BASE_URL\",\n \"embed_api_key\": \"INSIGHT_EYE_AI_EMBED_API_KEY\",\n}\n\n# 需要转 int 的字段\n_INT_FIELDS = {\n \"llm_timeout\",\n \"embed_dim\",\n \"embed_batch_size\",\n \"chunk_size\",\n \"chunk_overlap\",\n \"top_k\",\n \"max_upload_mb\",\n \"max_chunks_per_doc\",\n \"rerank_fetch_k\",\n}\n# 需要转 float 的字段\n_FLOAT_FIELDS = {\"min_score\", \"semantic_breakpoint\"}\n\n# 需要转 bool 的字段(\"1\"/\"true\"/\"yes\"/\"on\" → True,其余 False)\n_BOOL_FIELDS = {\"rerank_enabled\", \"ocr_enabled\", \"vector_enabled\"}\n\n\ndef get_active_provider(cfg: LLMConfig) -> dict | None:\n \"\"\"返回当前生效的 Provider dict(按 active_provider_id 查找);无则 None。\"\"\"\n if not cfg.providers or not cfg.active_provider_id:\n return None\n for p in cfg.providers:\n if isinstance(p, dict) and p.get(\"id\") == cfg.active_provider_id:\n return p\n return None\n\n\ndef apply_provider(cfg: LLMConfig, provider_id: str | None = None) -> LLMConfig:\n \"\"\"把指定 Provider(默认 active_provider_id)的 base_url/api_key/chat_model/vision_model\n 投影到扁平字段。切换/加载时调用,保证旧的读取路径无感知。\n\n provider_id=None 时用 cfg.active_provider_id;找不到则不动扁平字段(保持原值)。\n \"\"\"\n pid = provider_id or cfg.active_provider_id\n if not pid:\n return cfg\n target = None\n for p in cfg.providers:\n if isinstance(p, dict) and p.get(\"id\") == pid:\n target = p\n break\n if not target:\n return cfg\n cfg.active_provider_id = pid\n # 投影:Provider 字段 → 扁平字段(只覆盖非空字段,空则保留原值以兼容 vision_model 等可选字段)\n if target.get(\"base_url\"):\n cfg.llm_base_url = target[\"base_url\"]\n if target.get(\"api_key\"):\n cfg.llm_api_key = target[\"api_key\"]\n if target.get(\"chat_model\"):\n cfg.chat_model = target[\"chat_model\"]\n # vision_model 允许显式置空(用空串覆盖,表示\"回退 chat_model\")\n if \"vision_model\" in target:\n cfg.vision_model = target.get(\"vision_model\") or \"\"\n return cfg\n\n\ndef load_config(config_file: str | None = None) -> LLMConfig:\n \"\"\"按 默认值 → 配置文件 → 环境变量 的顺序叠加(后者覆盖前者)。\"\"\"\n cfg = LLMConfig()\n\n # 配置文件\n path = config_file or cfg.config_file\n path = os.path.expanduser(path)\n # 兼容旧文件名:llm_config.json 不存在时回退读 ai_config.json(上提前的文件名)\n if not config_file and not os.path.exists(path):\n legacy = os.path.join(os.path.dirname(path), \"ai_config.json\")\n if os.path.exists(legacy):\n path = legacy\n file_data: dict = {}\n if os.path.exists(path):\n try:\n with open(path, \"r\", encoding=\"utf-8\") as f:\n file_data = json.load(f)\n except (json.JSONDecodeError, OSError):\n file_data = {}\n for k, v in file_data.items():\n if hasattr(cfg, k):\n setattr(cfg, k, v)\n\n # Provider 投影(在 env 覆盖前):把 active provider 的字段投影到扁平字段。\n # 这样 env 仍是最高优先级(无头部署强制锁定),provider 仅在 env 未设时生效。\n if cfg.providers and cfg.active_provider_id:\n apply_provider(cfg)\n\n # 环境变量(最高优先级)\n for field_name, env_name in _ENV_KEYS.items():\n val = os.getenv(env_name)\n if val is None:\n continue\n if field_name in _INT_FIELDS:\n val = int(val)\n elif field_name in _FLOAT_FIELDS:\n val = float(val)\n elif field_name in _BOOL_FIELDS:\n val = str(val).strip().lower() in (\"1\", \"true\", \"yes\", \"on\")\n setattr(cfg, field_name, val)\n\n return cfg.expand_paths()\n\n\ndef save_config(cfg: LLMConfig, config_file: str | None = None) -> None:\n \"\"\"把配置写回文件(PUT /config 用)。db_path/docs_dir/config_file 不写回(运行期展开值)。\"\"\"\n path = config_file or cfg.config_file\n path = os.path.expanduser(path)\n os.makedirs(os.path.dirname(path), exist_ok=True)\n data = asdict(cfg)\n data.pop(\"db_path\", None)\n data.pop(\"docs_dir\", None)\n data.pop(\"config_file\", None)\n with open(path, \"w\", encoding=\"utf-8\") as f:\n json.dump(data, f, ensure_ascii=False, indent=2)\n\n\n# 向后兼容别名:ai 模块旧代码以 AIConfig 名字引用,保留无需改调用点\nAIConfig = LLMConfig\n"}}, "tests/ai/test_provider_config.py": {"88542831cfdb651ba9484aa1ef3571ebd9a73df0": {"t": 1785746879.949684, "n": 10868, "h": "3c68343b86127329", "c": "# -*- coding: utf-8 -*-\n\"\"\"Provider 多模型管理(Cursor 风格)测试。\n\n覆盖:\n- Provider CRUD(新增/更新/删除)\n- 切换 active provider 时投影到扁平字段\n- 第一个 provider 自动激活\n- active provider 不允许删除\n- 向后兼容:无 providers 时扁平字段仍可用\n- load_config 从文件加载 providers + 投影\n- GET /config/presets 返回预设清单\n\"\"\"\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\n\ndef _setup_app(tmp_path, monkeypatch):\n monkeypatch.setenv(\"INSIGHT_EYE_AI_LLM_API_KEY\", \"sk-test\")\n monkeypatch.setenv(\"INSIGHT_EYE_AI_EMBED_DIM\", \"4\")\n import insight_aitest.modules.ai.backend.deps as deps\n import insight_aitest.platform.services.kb.deps as kb_deps\n import insight_aitest.modules.ai.backend.persistence.database as _ai_db_mod\n deps._db = None\n deps._agent = None\n deps._planner = None\n kb_deps._llm_config = None\n kb_deps._kb_db = None\n kb_deps._llm = None\n kb_deps._vector_store = None\n kb_deps._retriever = None\n cfg_path = str(tmp_path / \"ai_config.json\")\n kb_deps.set_config_file(cfg_path)\n cfg = deps.get_config()\n cfg.db_path = str(tmp_path / \"kb.db\")\n cfg.docs_dir = str(tmp_path / \"docs\")\n cfg.config_file = cfg_path\n deps._db = _ai_db_mod.AIDatabase(str(tmp_path / \"ai.db\"))\n from insight_aitest.modules.ai.backend.routes import router as ai_router\n app = FastAPI()\n app.include_router(ai_router, prefix=\"/api/modules/ai\")\n return TestClient(app)\n\n\n# ===== T1: GET /config 默认含 providers 空列表 + active_provider_id 空串 =====\ndef test_get_config_has_provider_fields(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r = c.get(\"/api/modules/ai/config\")\n assert r.status_code == 200\n body = r.json()\n assert \"providers\" in body\n assert body[\"providers\"] == []\n assert body[\"active_provider_id\"] == \"\"\n\n\n# ===== T2: 新建 Provider(第一个自动激活)=====\ndef test_create_first_provider_auto_active(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-deepseek\",\n \"chat_model\": \"deepseek-chat\",\n })\n assert r.status_code == 200\n body = r.json()\n assert len(body[\"providers\"]) == 1\n p = body[\"providers\"][0]\n assert p[\"name\"] == \"DeepSeek\"\n assert p[\"chat_model\"] == \"deepseek-chat\"\n assert p[\"api_key_set\"] is True # 脱敏,只暴露是否设置\n assert \"api_key\" not in p # 明文 key 不外泄\n # 第一个 provider 自动激活\n assert body[\"active_provider_id\"] == p[\"id\"]\n # 激活后扁平字段被投影\n assert body[\"llm_base_url\"] == \"https://api.deepseek.com/v1\"\n assert body[\"chat_model\"] == \"deepseek-chat\"\n assert body[\"api_key_set\"] is True\n\n\n# ===== T3: 新建第二个 Provider 不自动激活(保持第一个 active)=====\ndef test_create_second_provider_no_auto_active(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n # 第一个\n c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n # 第二个\n r = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"OpenAI\", \"base_url\": \"https://api.openai.com/v1\",\n \"api_key\": \"sk-2\", \"chat_model\": \"gpt-4o-mini\",\n })\n body = r.json()\n assert len(body[\"providers\"]) == 2\n # active 仍是第一个\n first_id = body[\"providers\"][0][\"id\"]\n assert body[\"active_provider_id\"] == first_id\n # 扁平字段仍是第一个 provider 的值\n assert body[\"chat_model\"] == \"deepseek-chat\"\n\n\n# ===== T4: 切换 active provider,扁平字段跟着变 =====\ndef test_switch_active_provider(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n r2 = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"OpenAI\", \"base_url\": \"https://api.openai.com/v1\",\n \"api_key\": \"sk-2\", \"chat_model\": \"gpt-4o-mini\",\n })\n second_id = r2.json()[\"providers\"][1][\"id\"]\n\n # 切换到第二个\n r = c.put(f\"/api/modules/ai/config/activate/{second_id}\")\n assert r.status_code == 200\n body = r.json()\n assert body[\"active_provider_id\"] == second_id\n assert body[\"chat_model\"] == \"gpt-4o-mini\"\n assert body[\"llm_base_url\"] == \"https://api.openai.com/v1\"\n\n\n# ===== T5: active provider 不允许删除 =====\ndef test_cannot_delete_active_provider(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r1 = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n active_id = r1.json()[\"active_provider_id\"]\n\n r = c.delete(f\"/api/modules/ai/config/providers/{active_id}\")\n assert r.status_code == 400\n\n\n# ===== T6: 删除非 active provider =====\ndef test_delete_inactive_provider(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n r2 = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"OpenAI\", \"base_url\": \"https://api.openai.com/v1\",\n \"api_key\": \"sk-2\", \"chat_model\": \"gpt-4o-mini\",\n })\n inactive_id = r2.json()[\"providers\"][1][\"id\"]\n\n r = c.delete(f\"/api/modules/ai/config/providers/{inactive_id}\")\n assert r.status_code == 200\n assert len(r.json()[\"providers\"]) == 1\n\n\n# ===== T7: 更新 Provider(留空 api_key = 不改)=====\ndef test_update_provider_keep_key(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r1 = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n pid = r1.json()[\"providers\"][0][\"id\"]\n\n # 更新 chat_model,不传 api_key\n r = c.put(f\"/api/modules/ai/config/providers/{pid}\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"chat_model\": \"deepseek-reasoner\",\n })\n assert r.status_code == 200\n p = r.json()[\"providers\"][0]\n assert p[\"chat_model\"] == \"deepseek-reasoner\"\n assert p[\"api_key_set\"] is True # key 仍在\n\n\n# ===== T8: 向后兼容 —— 无 providers 时扁平字段 PUT 仍可用 =====\ndef test_flat_config_still_works_without_providers(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r = c.put(\"/api/modules/ai/config\", json={\n \"chat_model\": \"qwen-plus\",\n \"llm_base_url\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n })\n assert r.status_code == 200\n body = r.json()\n assert body[\"chat_model\"] == \"qwen-plus\"\n assert body[\"providers\"] == [] # 仍无 providers\n\n\n# ===== T9: GET /config/presets 返回预设清单 =====\ndef test_get_presets(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r = c.get(\"/api/modules/ai/config/presets\")\n assert r.status_code == 200\n presets = r.json()[\"presets\"]\n assert len(presets) >= 4\n names = [p[\"name\"] for p in presets]\n assert \"DeepSeek\" in names\n assert \"OpenAI\" in names\n # 每个 preset 有 models 列表\n for p in presets:\n assert \"base_url\" in p\n assert \"models\" in p\n assert len(p[\"models\"]) > 0\n\n\n# ===== T10: load_config 从文件加载 providers + 投影(单元级)=====\ndef test_load_config_projects_active_provider(tmp_path, monkeypatch):\n # 清掉所有 LLM 环境变量(env 优先级最高,会覆盖 provider 投影)\n for k in [\n \"INSIGHT_EYE_AI_LLM_API_KEY\", \"INSIGHT_EYE_AI_CHAT_MODEL\",\n \"INSIGHT_EYE_AI_LLM_BASE_URL\",\n ]:\n monkeypatch.delenv(k, raising=False)\n import json\n cfg_path = tmp_path / \"llm_config.json\"\n cfg_data = {\n \"providers\": [\n {\"id\": \"p1\", \"name\": \"A\", \"base_url\": \"http://a/v1\", \"api_key\": \"sk-a\", \"chat_model\": \"model-a\"},\n {\"id\": \"p2\", \"name\": \"B\", \"base_url\": \"http://b/v1\", \"api_key\": \"sk-b\", \"chat_model\": \"model-b\"},\n ],\n \"active_provider_id\": \"p2\",\n }\n cfg_path.write_text(json.dumps(cfg_data), encoding=\"utf-8\")\n\n from insight_aitest.platform.services.llm.config import load_config\n cfg = load_config(str(cfg_path))\n # 投影后扁平字段 = p2\n assert cfg.llm_base_url == \"http://b/v1\"\n assert cfg.llm_api_key == \"sk-b\"\n assert cfg.chat_model == \"model-b\"\n assert cfg.active_provider_id == \"p2\"\n assert len(cfg.providers) == 2\n\n\n# ===== T11: 向后兼容 —— 旧配置文件(无 providers 节)仍能加载 =====\ndef test_load_config_legacy_file_without_providers(tmp_path, monkeypatch):\n for k in [\n \"INSIGHT_EYE_AI_LLM_API_KEY\", \"INSIGHT_EYE_AI_CHAT_MODEL\",\n \"INSIGHT_EYE_AI_LLM_BASE_URL\",\n ]:\n monkeypatch.delenv(k, raising=False)\n import json\n cfg_path = tmp_path / \"llm_config.json\"\n # 旧格式:只有扁平字段,无 providers\n cfg_data = {\n \"llm_base_url\": \"https://api.openai.com/v1\",\n \"chat_model\": \"gpt-4o-mini\",\n \"llm_api_key\": \"sk-legacy\",\n }\n cfg_path.write_text(json.dumps(cfg_data), encoding=\"utf-8\")\n\n from insight_aitest.platform.services.llm.config import load_config\n cfg = load_config(str(cfg_path))\n assert cfg.llm_base_url == \"https://api.openai.com/v1\"\n assert cfg.chat_model == \"gpt-4o-mini\"\n assert cfg.llm_api_key == \"sk-legacy\"\n assert cfg.providers == []\n assert cfg.active_provider_id == \"\"\n\n\n# ===== T12: 环境变量优先级高于 provider 投影 =====\ndef test_env_overrides_provider_projection(tmp_path, monkeypatch):\n \"\"\"env 用于无头部署强制锁定,优先级最高。\"\"\"\n monkeypatch.setenv(\"INSIGHT_EYE_AI_LLM_API_KEY\", \"sk-env-override\")\n monkeypatch.setenv(\"INSIGHT_EYE_AI_CHAT_MODEL\", \"env-model\")\n monkeypatch.setenv(\"INSIGHT_EYE_AI_EMBED_DIM\", \"4\")\n import json\n cfg_path = tmp_path / \"llm_config.json\"\n cfg_data = {\n \"providers\": [\n {\"id\": \"p1\", \"name\": \"A\", \"base_url\": \"http://a/v1\", \"api_key\": \"sk-a\", \"chat_model\": \"model-a\"},\n ],\n \"active_provider_id\": \"p1\",\n }\n cfg_path.write_text(json.dumps(cfg_data), encoding=\"utf-8\")\n\n from insight_aitest.platform.services.llm.config import load_config\n cfg = load_config(str(cfg_path))\n # env 覆盖 provider 投影\n assert cfg.llm_api_key == \"sk-env-override\"\n assert cfg.chat_model == \"env-model\"\n\n\n# ===== T13: POST /config/test 走临时 client(不写配置)=====\ndef test_config_test_endpoint_no_side_effect(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n # 不连真实端点,必然失败,但不应抛 500(应返回 {ok: false, message: ...})\n r = c.post(\"/api/modules/ai/config/test\", json={\n \"base_url\": \"http://127.0.0.1:1/v1\", # 必然连不上\n \"api_key\": \"sk-test\",\n \"model\": \"test-model\",\n })\n assert r.status_code == 200\n body = r.json()\n assert body[\"ok\"] is False\n assert \"message\" in body\n # 配置不应改变\n r2 = c.get(\"/api/modules/ai/config\")\n assert r2.json()[\"providers\"] == []\n"}, "aba2a8f467caacc25b2ea5d7472ce6d6a645adb8": {"t": 1785768246.217152, "n": 10868, "h": "3c68343b86127329", "c": "# -*- coding: utf-8 -*-\n\"\"\"Provider 多模型管理(Cursor 风格)测试。\n\n覆盖:\n- Provider CRUD(新增/更新/删除)\n- 切换 active provider 时投影到扁平字段\n- 第一个 provider 自动激活\n- active provider 不允许删除\n- 向后兼容:无 providers 时扁平字段仍可用\n- load_config 从文件加载 providers + 投影\n- GET /config/presets 返回预设清单\n\"\"\"\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\n\ndef _setup_app(tmp_path, monkeypatch):\n monkeypatch.setenv(\"INSIGHT_EYE_AI_LLM_API_KEY\", \"sk-test\")\n monkeypatch.setenv(\"INSIGHT_EYE_AI_EMBED_DIM\", \"4\")\n import insight_aitest.modules.ai.backend.deps as deps\n import insight_aitest.platform.services.kb.deps as kb_deps\n import insight_aitest.modules.ai.backend.persistence.database as _ai_db_mod\n deps._db = None\n deps._agent = None\n deps._planner = None\n kb_deps._llm_config = None\n kb_deps._kb_db = None\n kb_deps._llm = None\n kb_deps._vector_store = None\n kb_deps._retriever = None\n cfg_path = str(tmp_path / \"ai_config.json\")\n kb_deps.set_config_file(cfg_path)\n cfg = deps.get_config()\n cfg.db_path = str(tmp_path / \"kb.db\")\n cfg.docs_dir = str(tmp_path / \"docs\")\n cfg.config_file = cfg_path\n deps._db = _ai_db_mod.AIDatabase(str(tmp_path / \"ai.db\"))\n from insight_aitest.modules.ai.backend.routes import router as ai_router\n app = FastAPI()\n app.include_router(ai_router, prefix=\"/api/modules/ai\")\n return TestClient(app)\n\n\n# ===== T1: GET /config 默认含 providers 空列表 + active_provider_id 空串 =====\ndef test_get_config_has_provider_fields(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r = c.get(\"/api/modules/ai/config\")\n assert r.status_code == 200\n body = r.json()\n assert \"providers\" in body\n assert body[\"providers\"] == []\n assert body[\"active_provider_id\"] == \"\"\n\n\n# ===== T2: 新建 Provider(第一个自动激活)=====\ndef test_create_first_provider_auto_active(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\",\n \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-deepseek\",\n \"chat_model\": \"deepseek-chat\",\n })\n assert r.status_code == 200\n body = r.json()\n assert len(body[\"providers\"]) == 1\n p = body[\"providers\"][0]\n assert p[\"name\"] == \"DeepSeek\"\n assert p[\"chat_model\"] == \"deepseek-chat\"\n assert p[\"api_key_set\"] is True # 脱敏,只暴露是否设置\n assert \"api_key\" not in p # 明文 key 不外泄\n # 第一个 provider 自动激活\n assert body[\"active_provider_id\"] == p[\"id\"]\n # 激活后扁平字段被投影\n assert body[\"llm_base_url\"] == \"https://api.deepseek.com/v1\"\n assert body[\"chat_model\"] == \"deepseek-chat\"\n assert body[\"api_key_set\"] is True\n\n\n# ===== T3: 新建第二个 Provider 不自动激活(保持第一个 active)=====\ndef test_create_second_provider_no_auto_active(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n # 第一个\n c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n # 第二个\n r = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"OpenAI\", \"base_url\": \"https://api.openai.com/v1\",\n \"api_key\": \"sk-2\", \"chat_model\": \"gpt-4o-mini\",\n })\n body = r.json()\n assert len(body[\"providers\"]) == 2\n # active 仍是第一个\n first_id = body[\"providers\"][0][\"id\"]\n assert body[\"active_provider_id\"] == first_id\n # 扁平字段仍是第一个 provider 的值\n assert body[\"chat_model\"] == \"deepseek-chat\"\n\n\n# ===== T4: 切换 active provider,扁平字段跟着变 =====\ndef test_switch_active_provider(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n r2 = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"OpenAI\", \"base_url\": \"https://api.openai.com/v1\",\n \"api_key\": \"sk-2\", \"chat_model\": \"gpt-4o-mini\",\n })\n second_id = r2.json()[\"providers\"][1][\"id\"]\n\n # 切换到第二个\n r = c.put(f\"/api/modules/ai/config/activate/{second_id}\")\n assert r.status_code == 200\n body = r.json()\n assert body[\"active_provider_id\"] == second_id\n assert body[\"chat_model\"] == \"gpt-4o-mini\"\n assert body[\"llm_base_url\"] == \"https://api.openai.com/v1\"\n\n\n# ===== T5: active provider 不允许删除 =====\ndef test_cannot_delete_active_provider(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r1 = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n active_id = r1.json()[\"active_provider_id\"]\n\n r = c.delete(f\"/api/modules/ai/config/providers/{active_id}\")\n assert r.status_code == 400\n\n\n# ===== T6: 删除非 active provider =====\ndef test_delete_inactive_provider(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n r2 = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"OpenAI\", \"base_url\": \"https://api.openai.com/v1\",\n \"api_key\": \"sk-2\", \"chat_model\": \"gpt-4o-mini\",\n })\n inactive_id = r2.json()[\"providers\"][1][\"id\"]\n\n r = c.delete(f\"/api/modules/ai/config/providers/{inactive_id}\")\n assert r.status_code == 200\n assert len(r.json()[\"providers\"]) == 1\n\n\n# ===== T7: 更新 Provider(留空 api_key = 不改)=====\ndef test_update_provider_keep_key(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r1 = c.put(\"/api/modules/ai/config/providers/new\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"api_key\": \"sk-1\", \"chat_model\": \"deepseek-chat\",\n })\n pid = r1.json()[\"providers\"][0][\"id\"]\n\n # 更新 chat_model,不传 api_key\n r = c.put(f\"/api/modules/ai/config/providers/{pid}\", json={\n \"name\": \"DeepSeek\", \"base_url\": \"https://api.deepseek.com/v1\",\n \"chat_model\": \"deepseek-reasoner\",\n })\n assert r.status_code == 200\n p = r.json()[\"providers\"][0]\n assert p[\"chat_model\"] == \"deepseek-reasoner\"\n assert p[\"api_key_set\"] is True # key 仍在\n\n\n# ===== T8: 向后兼容 —— 无 providers 时扁平字段 PUT 仍可用 =====\ndef test_flat_config_still_works_without_providers(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r = c.put(\"/api/modules/ai/config\", json={\n \"chat_model\": \"qwen-plus\",\n \"llm_base_url\": \"https://dashscope.aliyuncs.com/compatible-mode/v1\",\n })\n assert r.status_code == 200\n body = r.json()\n assert body[\"chat_model\"] == \"qwen-plus\"\n assert body[\"providers\"] == [] # 仍无 providers\n\n\n# ===== T9: GET /config/presets 返回预设清单 =====\ndef test_get_presets(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n r = c.get(\"/api/modules/ai/config/presets\")\n assert r.status_code == 200\n presets = r.json()[\"presets\"]\n assert len(presets) >= 4\n names = [p[\"name\"] for p in presets]\n assert \"DeepSeek\" in names\n assert \"OpenAI\" in names\n # 每个 preset 有 models 列表\n for p in presets:\n assert \"base_url\" in p\n assert \"models\" in p\n assert len(p[\"models\"]) > 0\n\n\n# ===== T10: load_config 从文件加载 providers + 投影(单元级)=====\ndef test_load_config_projects_active_provider(tmp_path, monkeypatch):\n # 清掉所有 LLM 环境变量(env 优先级最高,会覆盖 provider 投影)\n for k in [\n \"INSIGHT_EYE_AI_LLM_API_KEY\", \"INSIGHT_EYE_AI_CHAT_MODEL\",\n \"INSIGHT_EYE_AI_LLM_BASE_URL\",\n ]:\n monkeypatch.delenv(k, raising=False)\n import json\n cfg_path = tmp_path / \"llm_config.json\"\n cfg_data = {\n \"providers\": [\n {\"id\": \"p1\", \"name\": \"A\", \"base_url\": \"http://a/v1\", \"api_key\": \"sk-a\", \"chat_model\": \"model-a\"},\n {\"id\": \"p2\", \"name\": \"B\", \"base_url\": \"http://b/v1\", \"api_key\": \"sk-b\", \"chat_model\": \"model-b\"},\n ],\n \"active_provider_id\": \"p2\",\n }\n cfg_path.write_text(json.dumps(cfg_data), encoding=\"utf-8\")\n\n from insight_aitest.platform.services.llm.config import load_config\n cfg = load_config(str(cfg_path))\n # 投影后扁平字段 = p2\n assert cfg.llm_base_url == \"http://b/v1\"\n assert cfg.llm_api_key == \"sk-b\"\n assert cfg.chat_model == \"model-b\"\n assert cfg.active_provider_id == \"p2\"\n assert len(cfg.providers) == 2\n\n\n# ===== T11: 向后兼容 —— 旧配置文件(无 providers 节)仍能加载 =====\ndef test_load_config_legacy_file_without_providers(tmp_path, monkeypatch):\n for k in [\n \"INSIGHT_EYE_AI_LLM_API_KEY\", \"INSIGHT_EYE_AI_CHAT_MODEL\",\n \"INSIGHT_EYE_AI_LLM_BASE_URL\",\n ]:\n monkeypatch.delenv(k, raising=False)\n import json\n cfg_path = tmp_path / \"llm_config.json\"\n # 旧格式:只有扁平字段,无 providers\n cfg_data = {\n \"llm_base_url\": \"https://api.openai.com/v1\",\n \"chat_model\": \"gpt-4o-mini\",\n \"llm_api_key\": \"sk-legacy\",\n }\n cfg_path.write_text(json.dumps(cfg_data), encoding=\"utf-8\")\n\n from insight_aitest.platform.services.llm.config import load_config\n cfg = load_config(str(cfg_path))\n assert cfg.llm_base_url == \"https://api.openai.com/v1\"\n assert cfg.chat_model == \"gpt-4o-mini\"\n assert cfg.llm_api_key == \"sk-legacy\"\n assert cfg.providers == []\n assert cfg.active_provider_id == \"\"\n\n\n# ===== T12: 环境变量优先级高于 provider 投影 =====\ndef test_env_overrides_provider_projection(tmp_path, monkeypatch):\n \"\"\"env 用于无头部署强制锁定,优先级最高。\"\"\"\n monkeypatch.setenv(\"INSIGHT_EYE_AI_LLM_API_KEY\", \"sk-env-override\")\n monkeypatch.setenv(\"INSIGHT_EYE_AI_CHAT_MODEL\", \"env-model\")\n monkeypatch.setenv(\"INSIGHT_EYE_AI_EMBED_DIM\", \"4\")\n import json\n cfg_path = tmp_path / \"llm_config.json\"\n cfg_data = {\n \"providers\": [\n {\"id\": \"p1\", \"name\": \"A\", \"base_url\": \"http://a/v1\", \"api_key\": \"sk-a\", \"chat_model\": \"model-a\"},\n ],\n \"active_provider_id\": \"p1\",\n }\n cfg_path.write_text(json.dumps(cfg_data), encoding=\"utf-8\")\n\n from insight_aitest.platform.services.llm.config import load_config\n cfg = load_config(str(cfg_path))\n # env 覆盖 provider 投影\n assert cfg.llm_api_key == \"sk-env-override\"\n assert cfg.chat_model == \"env-model\"\n\n\n# ===== T13: POST /config/test 走临时 client(不写配置)=====\ndef test_config_test_endpoint_no_side_effect(tmp_path, monkeypatch):\n c = _setup_app(tmp_path, monkeypatch)\n # 不连真实端点,必然失败,但不应抛 500(应返回 {ok: false, message: ...})\n r = c.post(\"/api/modules/ai/config/test\", json={\n \"base_url\": \"http://127.0.0.1:1/v1\", # 必然连不上\n \"api_key\": \"sk-test\",\n \"model\": \"test-model\",\n })\n assert r.status_code == 200\n body = r.json()\n assert body[\"ok\"] is False\n assert \"message\" in body\n # 配置不应改变\n r2 = c.get(\"/api/modules/ai/config\")\n assert r2.json()[\"providers\"] == []\n"}}}}