1 line
46 KiB
JSON
1 line
46 KiB
JSON
{"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"}}}} |