From 4d8c6e20c429e1851a53ab1269c987c9741e8c72 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 08:55:18 +0800 Subject: [PATCH 01/31] build: configure ruff formatter with a 100 column limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 此前只配置了 ruff lint,未配置 formatter,格式化行为取决于默认值。 显式声明行宽与引号风格,让 `ruff format` 在任何机器上产出相同结果。 行宽取 100 而非默认 88:既有代码本就按约 100 列书写(88 列下有 1875 处 超限行,100 列下仅 664 处),沿用 88 会把大量完整表达式折成多行,产生 无意义的 diff。已核对 88/100/110/120 四档的实际改动行数,100 列比 88 列 少约 40% churn,且是通行标准。 Co-Authored-By: Claude Opus 5 (1M context) --- pyproject.toml | 10 ++++++++++ tests/chat/test_core.py | 6 +++--- tests/providers/test_security_boundaries.py | 6 +++--- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3f45b8e..5b97678 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,16 @@ dev = [ "ruff>=0.16.6", ] +[tool.ruff] +# 代码按约 100 列书写;88 列会让大量既有行被无意义折行。 +line-length = 100 +target-version = "py311" + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" + [tool.ruff.lint] # Provider and configuration boundaries intentionally expose ValueError for # malformed values as part of the existing public contract. diff --git a/tests/chat/test_core.py b/tests/chat/test_core.py index e16d3b9..2e8d534 100644 --- a/tests/chat/test_core.py +++ b/tests/chat/test_core.py @@ -407,7 +407,7 @@ def test_redacting_formatter_scrubs_cached_traceback_for_later_handlers(): import sys try: - raise RuntimeError("Authorization: Bearer sk-cached-traceback-secret") + raise RuntimeError("Authorization: Bearer sk-FAKE0000TRACEBACK0000FAKE0000") except RuntimeError: exc_info = sys.exc_info() @@ -422,5 +422,5 @@ def test_redacting_formatter_scrubs_cached_traceback_for_later_handlers(): logger.setLevel(logging.ERROR) logger.error("request failed", exc_info=exc_info) - assert "sk-cached-traceback-secret" not in redacting.stream.getvalue() - assert "sk-cached-traceback-secret" not in plain.stream.getvalue() + assert "sk-FAKE0000TRACEBACK0000FAKE0000" not in redacting.stream.getvalue() + assert "sk-FAKE0000TRACEBACK0000FAKE0000" not in plain.stream.getvalue() diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index 9343d80..72124df 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -141,14 +141,14 @@ def test_security_redacts_masked_credentials_after_bearer_prefix(value): def test_security_rejects_credential_hidden_behind_unparseable_url(): """``urlsplit`` 对畸形 URL 抛 ValueError。若该分支直接 return,任何凭证 都能随一个畸形前缀整体绕过边界校验(这是叶子值的唯一检查入口)。""" - unparseable = "https://[::1 token sk-abcdefghijklmnopqrstuvwxyz0123456789" + unparseable = "https://[::1 token sk-FAKE0000FAKE0000FAKE0000FAKE0000" with pytest.raises(ValueError, match="凭证"): validate_secret_free_options({"note": unparseable}, "Provider") def test_security_parseable_and_unparseable_urls_are_equally_strict(): """同样藏凭证的两个值,只因 URL 可解析与否而一个被拒一个放行,即为漏洞。""" - secret = "token sk-abcdefghijklmnopqrstuvwxyz0123456789" + secret = "token sk-FAKE0000FAKE0000FAKE0000FAKE0000" with pytest.raises(ValueError, match="凭证"): validate_secret_free_options({"note": f"https://h/x {secret}"}, "Provider") with pytest.raises(ValueError, match="凭证"): @@ -162,7 +162,7 @@ def test_safe_exception_text_redacts_credentials(): """SDK 异常常回显请求头或 URL,终端与日志是凭证最容易泄漏的出口。""" from src.utils.security import safe_exception_text - secret = "sk-abcdefghijklmnopqrstuvwxyz0123456789" + secret = "sk-FAKE0000FAKE0000FAKE0000FAKE0000" rendered = safe_exception_text(RuntimeError(f"auth failed for {secret}")) assert secret not in rendered From f87259d771e4da14c97c050a1c54c68ef72f07a6 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 08:55:49 +0800 Subject: [PATCH 02/31] style: apply ruff format across the repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对 `main.py`、`src/`、`scripts/`、`tests/` 执行 `ruff format`。改动均为 表示层:统一双引号、清除行尾空白、规范化空行与折行。 验证语义等价:对全部改动文件用 `ast` 比对格式化前后的语法树(剥离 docstring 后逐节点比较),语法树完全一致;单独比对 docstring 文本, 唯一差异是 `recursive_text_splitter` 中一行尾随空白被清除。 `nosec` 与 `type: ignore` 抑制均未失效——bandit 仍报 0 问题, mypy 仍报 0 错误。 Co-Authored-By: Claude Opus 5 (1M context) --- main.py | 112 +- scripts/build_binary_release.py | 3 +- scripts/embed_knowledge_base.py | 30 +- src/__init__.py | 2 +- src/chat/core.py | 26 +- src/etl/cleaners/basic_cleaner.py | 25 +- src/etl/pipeline.py | 38 +- src/etl/splitters/recursive_text_splitter.py | 25 +- src/models/__init__.py | 2 +- src/models/document.py | 1 + src/providers/__base__/model_provider.py | 147 +- src/providers/anthropic.py | 416 +++-- src/providers/deepseek.py | 11 +- src/providers/factory.py | 36 +- src/providers/google.py | 1411 +++++++++++------ src/providers/grok.py | 7 +- src/providers/jina.py | 51 +- src/providers/lm_studio.py | 11 +- src/providers/local_hash.py | 4 +- src/providers/ollama.py | 11 +- src/providers/openai.py | 54 +- src/providers/openai_compatible.py | 723 +++++---- src/providers/qwen.py | 7 +- src/providers/resources.py | 226 ++- src/providers/siliconflow.py | 11 +- src/providers/siliconflow_rerank.py | 45 +- src/providers/volcengine.py | 893 ++++++++--- src/retrieval/retriever.py | 4 +- src/retrieval/vdb/base.py | 8 +- src/retrieval/vdb/factory.py | 11 +- src/retrieval/vdb/faiss_store.py | 36 +- src/retrieval_test/core.py | 13 +- src/retrieval_test/excel_logger.py | 33 +- src/services/chat_service.py | 10 +- src/services/knowledge_build_service.py | 12 +- src/services/retrieval_service.py | 62 +- src/ui/config_menu.py | 125 +- src/ui/display_utils.py | 85 +- src/utils/cleanup.py | 8 +- src/utils/config.py | 159 +- src/utils/log_manager.py | 52 +- src/utils/security.py | 61 +- tests/chat/test_core.py | 8 +- tests/etl/test_pipeline.py | 39 +- tests/providers/test_factory.py | 76 +- tests/providers/test_failure_contracts.py | 10 +- tests/providers/test_google_genai.py | 60 +- tests/providers/test_protocol_adapters.py | 229 +-- tests/providers/test_sdk_capabilities.py | 869 ++++++---- .../retrieval/test_parent_child_retrieval.py | 34 +- tests/retrieval/vdb/test_factory.py | 37 +- tests/retrieval_test/test_retrieval_cli.py | 13 +- tests/services/test_embedding_service.py | 34 +- tests/test_config.py | 48 +- tests/test_release_scripts.py | 4 +- 55 files changed, 4226 insertions(+), 2242 deletions(-) diff --git a/main.py b/main.py index efcb21a..de2eecf 100644 --- a/main.py +++ b/main.py @@ -27,12 +27,15 @@ from src.utils.security import safe_exception_text console = Console() -VERSION = "1.4.0" # 程序版本 +VERSION = "1.4.0" # 程序版本 + # ================================================================= # 应用程序界面 (APP UI) # ================================================================= -def create_gradient(text: str, start_color: tuple[int, int, int], end_color: tuple[int, int, int]) -> Text: +def create_gradient( + text: str, start_color: tuple[int, int, int], end_color: tuple[int, int, int] +) -> Text: """为文本创建从左到右的水平颜色渐变效果。""" text_obj = Text() total_length = len(text) @@ -43,12 +46,13 @@ def create_gradient(text: str, start_color: tuple[int, int, int], end_color: tup text_obj.append(char, style=f"rgb({r},{g},{b})") return text_obj + def display_banner(): """显示程序的启动横幅。""" # 使用 'big' 字体,它是 standard 的加粗和放大版本,清晰且有冲击力 - fig = pyfiglet.Figlet(font='big') - banner_text = fig.renderText('PyRAG-Kit') - + fig = pyfiglet.Figlet(font="big") + banner_text = fig.renderText("PyRAG-Kit") + # 定义渐变色 (左蓝右红) blue_rgb = (0, 0, 255) red_rgb = (255, 0, 0) @@ -69,28 +73,31 @@ def display_banner(): if i == len(lines) - 2: line_content = line.rstrip() gradient_part = create_gradient(line_content, blue_rgb, red_rgb) - + # 计算填充,确保署名在右下角对齐 padding_size = banner_width - len(line_content) - len(attribution_text) padding_size = max(padding_size, 1) - + padding = Text(" " * padding_size) - + # 组合并打印该行 console.print(gradient_part + padding + attribution_text) else: # 其他行正常打印渐变效果 console.print(create_gradient(line, blue_rgb, red_rgb)) - + # 构建包含丰富链接和信息的欢迎面板 welcome_text = Text(justify="center") welcome_text.append(f"欢迎使用 PyRAG-Kit - 版本 {VERSION}\n", style="bold cyan") - welcome_text.append("一个 Dify 核心逻辑的 Python 实现,用于本地验证其知识库向量化、分段及检索流程。\n\n", style="dim") + welcome_text.append( + "一个 Dify 核心逻辑的 Python 实现,用于本地验证其知识库向量化、分段及检索流程。\n\n", + style="dim", + ) welcome_text.append("作者: ", style="bold") welcome_text.append("Mison", style="default") welcome_text.append(" · 邮箱: ", style="bold") welcome_text.append("1360962086@qq.com", style="default") - welcome_text.append("\n") # 换行 + welcome_text.append("\n") # 换行 welcome_text.append("GitHub: ", style="bold") # 使用正确的 GitHub 仓库地址 github_url = "https://github.com/MisonL/PyRAG-Kit" @@ -99,6 +106,7 @@ def display_banner(): # 设置面板宽度与 banner 宽度一致 console.print(Panel(welcome_text, border_style="green", width=banner_width)) + def display_menu(): """使用rich库显示美化的交互式菜单。""" menu_content = ( @@ -108,7 +116,16 @@ def display_menu(): "[bold cyan]3.[/bold cyan] 启动聊天机器人会话\n" "[bold cyan]4.[/bold cyan] 退出程序" ) - console.print(Panel(menu_content, title="[bold yellow]主菜单[/bold yellow]", border_style="green", expand=False, highlight=True)) + console.print( + Panel( + menu_content, + title="[bold yellow]主菜单[/bold yellow]", + border_style="green", + expand=False, + highlight=True, + ) + ) + # ================================================================= # 应用程序预热 (APP WARM-UP) @@ -119,26 +136,27 @@ def initialize_dependencies(): 同时,主动管理缓存文件的位置。 """ console.print("[dim]正在初始化依赖项...[/dim]") - + # 1. 执行日志清理 src.utils.log_manager.cleanup_old_logs() - + # 2. 使用从 settings 实例获取的缓存目录 cache_dir = get_settings().cache_path if not os.path.exists(cache_dir): os.makedirs(cache_dir) - + # 2. 预热jieba并完全抑制其所有启动日志 with redirect_stdout(StringIO()), redirect_stderr(StringIO()): import jieba import jieba.posseg as pseg - + jieba.setLogLevel(jieba.logging.ERROR) jieba.dt.tmp_dir = str(cache_dir) list(pseg.cut("")) console.print("[dim]依赖项初始化完成。[/dim]") + def run_smoke_test() -> int: """执行非交互式启动自检。""" settings = get_settings() @@ -165,26 +183,65 @@ def main(): display_menu() try: # 使用 prompt_toolkit 替代 console.input,并优化样式 - choice = prompt(HTML('请输入选项 (1-4): ')) - if choice == '1': - console.print(Panel("[bold green]开始执行知识库文档向量化处理...[/bold green]", border_style="green", width=CONSOLE_WIDTH)) + choice = prompt(HTML("请输入选项 (1-4): ")) + if choice == "1": + console.print( + Panel( + "[bold green]开始执行知识库文档向量化处理...[/bold green]", + border_style="green", + width=CONSOLE_WIDTH, + ) + ) # 延迟加载和执行 from scripts.embed_knowledge_base import main as run_embedding_process + run_embedding_process() - console.print(Panel("[bold green]向量化处理完成。[/bold green]", border_style="green", width=CONSOLE_WIDTH)) - elif choice == '2': - console.print(Panel("[bold green]开始执行召回测试...[/bold green]", border_style="green", width=CONSOLE_WIDTH)) + console.print( + Panel( + "[bold green]向量化处理完成。[/bold green]", + border_style="green", + width=CONSOLE_WIDTH, + ) + ) + elif choice == "2": + console.print( + Panel( + "[bold green]开始执行召回测试...[/bold green]", + border_style="green", + width=CONSOLE_WIDTH, + ) + ) # 延迟加载和执行 from src.retrieval_test.core import run_retrieval_test + run_retrieval_test() - console.print(Panel("[bold green]召回测试完成。[/bold green]", border_style="green", width=CONSOLE_WIDTH)) - elif choice == '3': - console.print(Panel("[bold green]启动聊天机器人会话...[/bold green]", border_style="green", width=CONSOLE_WIDTH)) + console.print( + Panel( + "[bold green]召回测试完成。[/bold green]", + border_style="green", + width=CONSOLE_WIDTH, + ) + ) + elif choice == "3": + console.print( + Panel( + "[bold green]启动聊天机器人会话...[/bold green]", + border_style="green", + width=CONSOLE_WIDTH, + ) + ) # 延迟加载和执行 from src.chat.core import start_chat_session + start_chat_session() - console.print(Panel("[bold green]聊天会话结束。[/bold green]", border_style="green", width=CONSOLE_WIDTH)) - elif choice == '4': + console.print( + Panel( + "[bold green]聊天会话结束。[/bold green]", + border_style="green", + width=CONSOLE_WIDTH, + ) + ) + elif choice == "4": console.print("[bold]正在退出程序... 再见![/bold]") sys.exit(0) else: @@ -196,5 +253,6 @@ def main(): console.print(f"\n[bold red]程序运行期间发生错误:[/bold red] {safe_exception_text(e)}") console.print("[bold red]请检查错误信息并重试。[/bold red]") + if __name__ == "__main__": sys.exit(run_cli()) diff --git a/scripts/build_binary_release.py b/scripts/build_binary_release.py index e07246d..05f11ea 100755 --- a/scripts/build_binary_release.py +++ b/scripts/build_binary_release.py @@ -188,8 +188,7 @@ def prepare_runtime_layout(bundle_root: Path) -> None: placeholder = knowledge_base_dir / "README.md" placeholder.write_text( - "# 知识库目录\n\n" - "请将您的 Markdown 知识库文档放入当前目录,然后再执行知识库构建。\n", + "# 知识库目录\n\n请将您的 Markdown 知识库文档放入当前目录,然后再执行知识库构建。\n", encoding="utf-8", ) diff --git a/scripts/embed_knowledge_base.py b/scripts/embed_knowledge_base.py index fcc20c2..85e62e8 100644 --- a/scripts/embed_knowledge_base.py +++ b/scripts/embed_knowledge_base.py @@ -43,23 +43,39 @@ def mask_api_key(key: str | None) -> str: table.add_column(justify="right", style="cyan", no_wrap=True, width=28) table.add_column(style="bright_white") table.add_row("[bold green]知识库配置[/bold green]", "") - table.add_row("知识库目录", f"[bold cyan]{get_relative_path(str(run_config.knowledge_base_path))}[/bold cyan]") - table.add_row("快照根目录", f"[bold cyan]{get_relative_path(str(run_config.snapshot_root))}[/bold cyan]") - table.add_row("旧版 pkl 路径", f"[bold cyan]{get_relative_path(str(run_config.legacy_pkl_path))}[/bold cyan]") + table.add_row( + "知识库目录", + f"[bold cyan]{get_relative_path(str(run_config.knowledge_base_path))}[/bold cyan]", + ) + table.add_row( + "快照根目录", f"[bold cyan]{get_relative_path(str(run_config.snapshot_root))}[/bold cyan]" + ) + table.add_row( + "旧版 pkl 路径", + f"[bold cyan]{get_relative_path(str(run_config.legacy_pkl_path))}[/bold cyan]", + ) table.add_row("索引模式", f"[bold magenta]{splitter_structure_mode}[/bold magenta]") table.add_row("文本切分块大小", f"[bold magenta]{run_config.kb_chunk_size}[/bold magenta]") table.add_row("切分重叠量", f"[bold magenta]{run_config.kb_chunk_overlap}[/bold magenta]") table.add_row("子分段块大小", f"[bold magenta]{run_config.kb_child_chunk_size}[/bold magenta]") - table.add_row("子分段重叠量", f"[bold magenta]{run_config.kb_child_chunk_overlap}[/bold magenta]") - table.add_row("嵌入批大小", f"[bold magenta]{run_config.kb_embedding_batch_size}[/bold magenta]") + table.add_row( + "子分段重叠量", f"[bold magenta]{run_config.kb_child_chunk_overlap}[/bold magenta]" + ) + table.add_row( + "嵌入批大小", f"[bold magenta]{run_config.kb_embedding_batch_size}[/bold magenta]" + ) embedding_key = run_config.default_embedding_provider embedding_detail = run_config.embedding_configurations[embedding_key] provider = embedding_detail.provider table.add_section() table.add_row("[bold green]模型与 API 配置[/bold green]", "") - table.add_row("激活嵌入提供商", f"[bold green]{embedding_key}[/bold green] ([dim]{provider}[/dim])") - table.add_row("模型名称", f"[bold bright_white]{embedding_detail.model_name}[/bold bright_white]") + table.add_row( + "激活嵌入提供商", f"[bold green]{embedding_key}[/bold green] ([dim]{provider}[/dim])" + ) + table.add_row( + "模型名称", f"[bold bright_white]{embedding_detail.model_name}[/bold bright_white]" + ) if provider == "local-hash": table.add_row("API Key", "[dim]本地模型,无需设置[/dim]") else: diff --git a/src/__init__.py b/src/__init__.py index 8c4c343..efdd436 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,2 +1,2 @@ # This file makes the 'src' directory a Python package. -# 这个文件使得 'src' 目录成为一个 Python 包。 \ No newline at end of file +# 这个文件使得 'src' 目录成为一个 Python 包。 diff --git a/src/chat/core.py b/src/chat/core.py index cf77b25..e073d18 100644 --- a/src/chat/core.py +++ b/src/chat/core.py @@ -172,7 +172,9 @@ async def aclose(self) -> None: safe_exception_text(exc), ) - def apply_config_update(self, updated_config: SessionConfig | dict[str, Any], llm_needs_reload: bool) -> None: + def apply_config_update( + self, updated_config: SessionConfig | dict[str, Any], llm_needs_reload: bool + ) -> None: previous_config = deepcopy(self.session_config) if llm_needs_reload else None self.chat_config = updated_config if not llm_needs_reload: @@ -199,9 +201,11 @@ async def apply_config_update_async( try: llm_key = self.session_config.active_llm_configuration configurations = getattr(self.session_config, "llm_configurations", None) - new_model = ModelProviderFactory.get_llm_provider( - llm_key, configurations - ) if configurations is not None else ModelProviderFactory.get_llm_provider(llm_key) + new_model = ( + ModelProviderFactory.get_llm_provider(llm_key, configurations) + if configurations is not None + else ModelProviderFactory.get_llm_provider(llm_key) + ) if self.retrieval_service is None: raise RuntimeError("检索服务尚未初始化。") chat_service = ChatService(new_model, self.retrieval_service) @@ -228,7 +232,9 @@ async def _retrieve_knowledge_async(self, retrieval_query: str) -> list[dict[str retrieval_service = getattr(self, "retrieval_service", None) if retrieval_service is None: return [] - return await retrieval_service.retrieve(retrieval_query, self.session_config, console=self.console) + return await retrieval_service.retrieve( + retrieval_query, self.session_config, console=self.console + ) async def chat_async(self, user_input: str) -> AsyncGenerator[str, None]: intent = user_input @@ -308,11 +314,13 @@ async def start_chat_session_async(): if bot.llm_model: display_chat_config(console, bot.chat_config) - console.print(f"客服已就绪 ([bold green]{bot.chat_config['active_llm_configuration']}[/bold green])") + console.print( + f"客服已就绪 ([bold green]{bot.chat_config['active_llm_configuration']}[/bold green])" + ) while True: try: - user_query = await session.prompt_async(HTML('你: ')) + user_query = await session.prompt_async(HTML("你: ")) if not user_query.strip(): continue if user_query.lower() == "/quit": @@ -348,7 +356,9 @@ async def start_chat_session_async(): with Live(response_panel, console=console, refresh_per_second=10) as live: async for chunk in bot.chat_async(user_query): full_response += chunk - live.update(Panel(Text(full_response), title="客服", border_style="green")) + live.update( + Panel(Text(full_response), title="客服", border_style="green") + ) except (KeyboardInterrupt, EOFError): break diff --git a/src/etl/cleaners/basic_cleaner.py b/src/etl/cleaners/basic_cleaner.py index cd168fc..bd61126 100644 --- a/src/etl/cleaners/basic_cleaner.py +++ b/src/etl/cleaners/basic_cleaner.py @@ -30,25 +30,22 @@ def clean(self, documents: list[Document], **kwargs) -> list[Document]: for doc in documents: # 直接访问 Document 对象的属性 content = doc.content - + # 1. 将所有回车换行符统一为单个换行符 - content = content.replace('\r\n', '\n') - + content = content.replace("\r\n", "\n") + # 2. 临时替换双换行符,以防止它们在后续的空白字符处理中被破坏 double_newline_placeholder = "__DOUBLE_NEWLINE_PLACEHOLDER__" - content = content.replace('\n\n', double_newline_placeholder) - + content = content.replace("\n\n", double_newline_placeholder) + # 3. 将所有连续的空白字符(包括单个换行符、制表符、多个空格)替换为单个空格 - content = re.sub(r'\s+', ' ', content) - + content = re.sub(r"\s+", " ", content) + # 4. 将占位符替换回双换行符 - content = content.replace(double_newline_placeholder, '\n\n') - + content = content.replace(double_newline_placeholder, "\n\n") + # 5. 移除字符串首尾的空白字符 content = content.strip() - - cleaned_documents.append(Document( - content=content, - metadata=doc.metadata - )) + + cleaned_documents.append(Document(content=content, metadata=doc.metadata)) return cleaned_documents diff --git a/src/etl/pipeline.py b/src/etl/pipeline.py index a880d25..a3f9f3e 100644 --- a/src/etl/pipeline.py +++ b/src/etl/pipeline.py @@ -16,6 +16,7 @@ logger = get_module_logger(__name__) + class Pipeline: """ 文档处理流水线。 @@ -23,10 +24,7 @@ class Pipeline: 已注入 CSE 性能传感器。 """ - def __init__(self, - extractor: BaseExtractor, - cleaner: BaseCleaner, - splitter: BaseSplitter): + def __init__(self, extractor: BaseExtractor, cleaner: BaseCleaner, splitter: BaseSplitter): """ 初始化 Pipeline。 Args: @@ -40,46 +38,50 @@ def __init__(self, logger.info("ETL Pipeline 初始化完成。") @classmethod - def from_file_path(cls, file_path: Path, splitter_structure_mode: str = "standard") -> "Pipeline": + def from_file_path( + cls, file_path: Path, splitter_structure_mode: str = "standard" + ) -> "Pipeline": """ 根据文件路径创建并初始化 Pipeline 实例。 """ logger.info(f"正在从文件路径 '{file_path}' 创建 ETL Pipeline。") extractor_instance: BaseExtractor = MarkdownExtractor() cleaner_instance: BaseCleaner = BasicCleaner() - splitter_instance: BaseSplitter = RecursiveTextSplitter(structure_mode=splitter_structure_mode) - + splitter_instance: BaseSplitter = RecursiveTextSplitter( + structure_mode=splitter_structure_mode + ) + return cls( - extractor=extractor_instance, - cleaner=cleaner_instance, - splitter=splitter_instance + extractor=extractor_instance, cleaner=cleaner_instance, splitter=splitter_instance ) def process(self, document: Document) -> list[Document]: """ 处理单个文档,并监控每一步的耗时 (CSE Sensor)。 """ - source = document.metadata.get('source', '未知来源') + source = document.metadata.get("source", "未知来源") logger.info(f"开始处理文档: {source}") - + start_total = time.perf_counter() - + # 抽取 start_step = time.perf_counter() extracted_docs = self.extractor.extract(document) logger.info(f"抽取完成: {source}, 耗时: {time.perf_counter() - start_step:.4f}s") - + # 清洗 start_step = time.perf_counter() cleaned_docs = self.cleaner.clean(extracted_docs) logger.info(f"清洗完成: {source}, 耗时: {time.perf_counter() - start_step:.4f}s") - + # 分割 start_step = time.perf_counter() final_chunks = self.splitter.split(cleaned_docs) - logger.info(f"分割完成: {source}, 耗时: {time.perf_counter() - start_step:.4f}s, 分片数: {len(final_chunks)}") - + logger.info( + f"分割完成: {source}, 耗时: {time.perf_counter() - start_step:.4f}s, 分片数: {len(final_chunks)}" + ) + total_duration = time.perf_counter() - start_total logger.info(f"文档总处理时长: {source}, 总计: {total_duration:.2f}s") - + return final_chunks diff --git a/src/etl/splitters/recursive_text_splitter.py b/src/etl/splitters/recursive_text_splitter.py index 1828d98..48b6de2 100644 --- a/src/etl/splitters/recursive_text_splitter.py +++ b/src/etl/splitters/recursive_text_splitter.py @@ -13,6 +13,7 @@ logger = get_module_logger(__name__) + class RecursiveTextSplitter(BaseSplitter): """ 递归文本分割器。 @@ -32,7 +33,7 @@ def __init__( ): """ 初始化 RecursiveTextSplitter。 - + Args: mode (str): 分割模式,可选 "char" 或 "token"。 encoding_name (str): tiktoken 编码名称。 @@ -66,10 +67,12 @@ def _init_splitter(self): current_settings = get_settings() self.text_splitter = RecursiveCharacterTextSplitter( chunk_size=self.parent_chunk_size or current_settings.kb_chunk_size, - chunk_overlap=self.parent_chunk_overlap if self.parent_chunk_overlap is not None else current_settings.kb_chunk_overlap, + chunk_overlap=self.parent_chunk_overlap + if self.parent_chunk_overlap is not None + else current_settings.kb_chunk_overlap, separators=current_settings.kb_splitter_separators, length_function=self._get_length_function(), - is_separator_regex=False + is_separator_regex=False, ) logger.info( "文本分割器已就绪: chunk_size=%s, mode=%s, structure=%s", @@ -82,7 +85,9 @@ def _build_child_splitter(self) -> RecursiveCharacterTextSplitter: """构建子分片器。""" current_settings = get_settings() return RecursiveCharacterTextSplitter( - chunk_size=self.child_chunk_size if self.child_chunk_size is not None else current_settings.kb_child_chunk_size, + chunk_size=self.child_chunk_size + if self.child_chunk_size is not None + else current_settings.kb_child_chunk_size, chunk_overlap=( self.child_chunk_overlap if self.child_chunk_overlap is not None @@ -104,7 +109,9 @@ def _split_standard_documents(self, documents: list[Document]) -> list[Document] for doc in documents: logger.debug(f"正在分割文档: {doc.metadata.get('source', '未知来源')}") - langchain_chunks = self.text_splitter.create_documents([doc.content], metadatas=[doc.metadata]) + langchain_chunks = self.text_splitter.create_documents( + [doc.content], metadatas=[doc.metadata] + ) for i, chunk in enumerate(langchain_chunks): chunk_content = self._strip_leading_punctuation(chunk.page_content) if not chunk_content: @@ -133,7 +140,9 @@ def split_hierarchical(self, documents: list[Document]) -> list[Document]: for doc in documents: logger.debug(f"正在层级分割文档: {doc.metadata.get('source', '未知来源')}") - parent_documents = self.text_splitter.create_documents([doc.content], metadatas=[doc.metadata]) + parent_documents = self.text_splitter.create_documents( + [doc.content], metadatas=[doc.metadata] + ) doc_chunk_count = 0 for parent_index, parent_doc in enumerate(parent_documents): @@ -149,7 +158,9 @@ def split_hierarchical(self, documents: list[Document]) -> list[Document]: "parent_chunk_index": parent_index, }, } - child_documents = child_splitter.create_documents([parent_content], metadatas=[parent_doc.metadata]) + child_documents = child_splitter.create_documents( + [parent_content], metadatas=[parent_doc.metadata] + ) for child_index, child_doc in enumerate(child_documents): child_content = self._strip_leading_punctuation(child_doc.page_content) diff --git a/src/models/__init__.py b/src/models/__init__.py index 109985f..fb0ecdf 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -1,2 +1,2 @@ # This file makes 'src/models' a Python package. -# 此文件将 'src/models' 标记为一个 Python 包。 \ No newline at end of file +# 此文件将 'src/models' 标记为一个 Python 包。 diff --git a/src/models/document.py b/src/models/document.py index 8ff1c1f..73d77e5 100644 --- a/src/models/document.py +++ b/src/models/document.py @@ -12,6 +12,7 @@ class Document(BaseModel): 表示一个文档块的Pydantic模型。 用于在ETL流水线和检索过程中传递文本内容及其相关元数据。 """ + content: str metadata: dict[str, Any] = Field(default_factory=dict) diff --git a/src/providers/__base__/model_provider.py b/src/providers/__base__/model_provider.py index da631a6..4b1fc7d 100644 --- a/src/providers/__base__/model_provider.py +++ b/src/providers/__base__/model_provider.py @@ -511,9 +511,7 @@ def _responses_provider_variant(provider: str) -> str: # Ark 专属的 Responses 内容块类型;OpenAI 兼容端点不接受这些块。 -_ARK_ONLY_RESPONSES_ITEM_TYPES = frozenset( - {"input_audio", "audio_url", "input_video", "video_url"} -) +_ARK_ONLY_RESPONSES_ITEM_TYPES = frozenset({"input_audio", "audio_url", "input_video", "video_url"}) def _reject_unsupported_responses_item( @@ -533,13 +531,9 @@ def _reject_unsupported_responses_item( return item_type = item.get("type") if item_type in _ARK_ONLY_RESPONSES_ITEM_TYPES: - raise ValueError( - f"{location} 的 {item_type} 内容块当前不受 Responses SDK 支持。" - ) + raise ValueError(f"{location} 的 {item_type} 内容块当前不受 Responses SDK 支持。") if "image_pixel_limit" in item: - raise ValueError( - f"{location}.image_pixel_limit 不受 OpenAI Responses SDK 支持。" - ) + raise ValueError(f"{location}.image_pixel_limit 不受 OpenAI Responses SDK 支持。") def _responses_content_part( @@ -578,9 +572,7 @@ def _responses_content_part( if part_type in {"audio_url", "input_audio"}: if variant != "ark": - raise ValueError( - f"{location} 的 {part_type} 内容块当前不受 Responses SDK 支持。" - ) + raise ValueError(f"{location} 的 {part_type} 内容块当前不受 Responses SDK 支持。") audio_value = source.get("audio_url") if isinstance(audio_value, Mapping): audio_url = audio_value.get("url") or audio_value.get("audio_url") @@ -607,9 +599,7 @@ def _responses_content_part( if part_type in {"video_url", "input_video"}: if variant != "ark": - raise ValueError( - f"{location} 的 {part_type} 内容块当前不受 Responses SDK 支持。" - ) + raise ValueError(f"{location} 的 {part_type} 内容块当前不受 Responses SDK 支持。") video_value = source.get("video_url") if isinstance(video_value, Mapping): video_url = video_value.get("url") or video_value.get("video_url") @@ -756,9 +746,7 @@ def _responses_content_part( if "file_url" not in source and isinstance(source.get("url"), str): source["file_url"] = source["url"] if not any(source.get(key) for key in ("file_id", "file_data", "file_url")): - raise ValueError( - f"{location} 缺少 file_id、file_data 或 file_url。" - ) + raise ValueError(f"{location} 缺少 file_id、file_data 或 file_url。") for key in ("file_id", "file_data", "file_url", "filename"): if source.get(key) is not None and not isinstance(source[key], str): raise ValueError(f"{location}.{key} 必须是字符串。") @@ -781,9 +769,7 @@ def _responses_content_part( if source.get(key) is not None and not isinstance(source[key], str): raise ValueError(f"{location}.{key} 必须是字符串。") if not any(source.get(key) for key in ("file_id", "file_data", "file_url")): - raise ValueError( - f"{location} 缺少 file_id、file_data 或 file_url。" - ) + raise ValueError(f"{location} 缺少 file_id、file_data 或 file_url。") converted = {"type": "input_file"} if source.get("detail") is not None: if variant != "openai" or source["detail"] not in {"auto", "low", "high"}: @@ -800,9 +786,7 @@ def _responses_content_part( if not isinstance(part_type, str) or not part_type.strip(): raise ValueError(f"{location}.type 必须是非空字符串。") - raise ValueError( - f"{location} 的内容类型 {part_type} 不受 Responses SDK 支持。" - ) + raise ValueError(f"{location} 的内容类型 {part_type} 不受 Responses SDK 支持。") def _responses_message_content( @@ -988,9 +972,8 @@ def normalize_responses_input( role = message.get("role") tool_calls = message.get("tool_calls") if role == "assistant" and tool_calls is not None: - if ( - isinstance(tool_calls, (str, bytes, bytearray)) - or not isinstance(tool_calls, Sequence) + if isinstance(tool_calls, (str, bytes, bytearray)) or not isinstance( + tool_calls, Sequence ): raise ValueError(f"{location}.tool_calls 必须是工具调用序列。") content = message.get("content") @@ -1009,9 +992,7 @@ def normalize_responses_input( converted.append(assistant_message) for call_index, tool_call in enumerate(tool_calls): if not isinstance(tool_call, Mapping): - raise ValueError( - f"{location}.tool_calls[{call_index}] 必须是对象。" - ) + raise ValueError(f"{location}.tool_calls[{call_index}] 必须是对象。") converted.append( _responses_function_call_item( tool_call, @@ -1048,11 +1029,11 @@ def normalize_responses_input( return converted - def normalize_usage(usage: Any) -> dict[str, Any]: """将常见供应商 usage 结构规范为统一 token 字段。""" if usage is None: return {} + def usage_key(prefix: str) -> str: return f"{prefix}_tokens" @@ -1072,10 +1053,16 @@ def usage_key(prefix: str) -> str: if value is not None: result[target] = value for key in ( - "prompt_tokens", "completion_tokens", "total_tokens", "input_tokens", - "output_tokens", "cached_tokens", "reasoning_tokens", + "prompt_tokens", + "completion_tokens", + "total_tokens", + "input_tokens", + "output_tokens", + "cached_tokens", + "reasoning_tokens", "tool_use_prompt_tokens", - "cache_creation_input_tokens", "cache_read_input_tokens", + "cache_creation_input_tokens", + "cache_read_input_tokens", ): value = field(usage, key) if value is not None: @@ -1093,7 +1080,11 @@ def extract_system_prompt(messages: Sequence[Mapping[str, Any]] | None) -> str | """提取消息列表中的 system 内容,供无 system role 的 SDK 使用。""" if not messages: return None - values = [content_to_text(message.get("content", "")) for message in messages if message.get("role") == "system"] + values = [ + content_to_text(message.get("content", "")) + for message in messages + if message.get("role") == "system" + ] value = "\n\n".join(item for item in values if item) return value or None @@ -1137,9 +1128,7 @@ def validate_complete_tool_call(tool_call: Mapping[str, Any], provider: str) -> try: json.loads(arguments) except json.JSONDecodeError as exc: - raise RuntimeError( - f"{provider} 工具调用 {name} 的 arguments 不是完整 JSON。" - ) from exc + raise RuntimeError(f"{provider} 工具调用 {name} 的 arguments 不是完整 JSON。") from exc return dict(tool_call) @@ -1190,7 +1179,10 @@ def is_retryable_error(error: BaseException) -> bool: numeric_status = int(status) return numeric_status == 429 or numeric_status >= 500 name = error.__class__.__name__.lower() - return any(token in name for token in ("timeout", "connection", "ratelimit", "internalserver", "serviceunavailable")) + return any( + token in name + for token in ("timeout", "connection", "ratelimit", "internalserver", "serviceunavailable") + ) def reject_unsupported_kwargs(provider: str, values: Mapping[str, Any]) -> None: @@ -1302,7 +1294,9 @@ def raise_for_stream_error_event(event: Any, provider: str) -> None: if isinstance(current, Mapping): values = [current.get(key) for key in ("message", "detail", "reason", "error", "body")] else: - values = [field(current, key) for key in ("message", "detail", "reason", "error", "body")] + values = [ + field(current, key) for key in ("message", "detail", "reason", "error", "body") + ] for value in values: if isinstance(value, str) and value.strip(): message = value.strip() @@ -1361,6 +1355,7 @@ def retry_sync_stream( first_event_validator: Callable[[Any], None] | None = None, ) -> Generator[Any, None, None]: """只重试同步流在首个事件前的建立阶段,避免重复已输出内容。""" + def open_and_peek() -> tuple[Any, Any]: iterator = iter(factory()) try: @@ -1392,6 +1387,7 @@ async def retry_async_stream( first_event_validator: Callable[[Any], None] | None = None, ) -> AsyncGenerator[Any, None]: """只重试异步流在首个事件前的建立阶段,避免重复已输出内容。""" + async def open_and_peek() -> tuple[Any, Any]: response = factory() response = await response if inspect.isawaitable(response) else response @@ -1428,6 +1424,7 @@ async def open_and_peek() -> tuple[Any, Any]: @contextmanager def retry_sync_stream_context(factory: Callable[[], Any]) -> Any: """重试同步流上下文的进入阶段,不重试已经开始的事件消费。""" + def enter() -> tuple[Any, Any]: manager = factory() try: @@ -1451,6 +1448,7 @@ def enter() -> tuple[Any, Any]: @asynccontextmanager async def retry_async_stream_context(factory: Callable[[], Any]) -> Any: """重试异步流上下文的进入阶段,不重试已经开始的事件消费。""" + async def enter() -> tuple[Any, Any]: manager = factory() try: @@ -1520,9 +1518,7 @@ def _is_duplicate_argument_error(error: TypeError) -> bool: """识别 SDK 调用中由显式参数与 ``**kwargs`` 重复造成的 TypeError。""" message = str(error).lower() - return "multiple values for" in message and ( - "argument" in message or "keyword" in message - ) + return "multiple values for" in message and ("argument" in message or "keyword" in message) def _sanitize_resource_call( @@ -1546,15 +1542,9 @@ def _sanitize_resource_call( return copied_args, _validate_secret_free_resource_kwargs(copied_kwargs, label) extension_names = {"extra_headers", "extra_query", "extra_body"} - explicit = { - name: bound.arguments[name] - for name in extension_names - if name in bound.arguments - } + explicit = {name: bound.arguments[name] for name in extension_names if name in bound.arguments} if explicit: - bound.arguments.update( - _validate_secret_free_resource_kwargs(explicit, label) - ) + bound.arguments.update(_validate_secret_free_resource_kwargs(explicit, label)) for name, parameter in signature.parameters.items(): if parameter.kind is inspect.Parameter.VAR_KEYWORD and name in bound.arguments: bound.arguments[name] = _validate_secret_free_resource_kwargs( @@ -1577,9 +1567,7 @@ def _guard_provider_resource_method(method: Callable[..., Any]) -> Callable[..., async def async_wrapper(provider: Any, *args: Any, **kwargs: Any) -> Any: # 凭证校验先行:它约束参数本身,与渠道是否具备该能力无关。 # 顺序反了会让携带凭证的调用报出能力错误,把安全问题掩盖成配置问题。 - safe_args, safe_kwargs = _sanitize_resource_call( - method, provider, args, kwargs - ) + safe_args, safe_kwargs = _sanitize_resource_call(method, provider, args, kwargs) resource_guard = getattr(provider, "_require_provider_resource", None) if callable(resource_guard): resource_guard(method.__name__) @@ -1609,9 +1597,7 @@ async def async_wrapper(provider: Any, *args: Any, **kwargs: Any) -> Any: @wraps(method) def wrapper(provider: Any, *args: Any, **kwargs: Any) -> Any: # 与 async_wrapper 保持同一顺序:先凭证,后能力。 - safe_args, safe_kwargs = _sanitize_resource_call( - method, provider, args, kwargs - ) + safe_args, safe_kwargs = _sanitize_resource_call(method, provider, args, kwargs) resource_guard = getattr(provider, "_require_provider_resource", None) if callable(resource_guard): resource_guard(method.__name__) @@ -1622,26 +1608,18 @@ def wrapper(provider: Any, *args: Any, **kwargs: Any) -> Any: return method(provider, *safe_args, **safe_kwargs) except TypeError as exc: if "unexpected keyword argument" in str(exc): - raise ValueError( - f"{_provider_label(provider)} 资源 SDK 参数不匹配: {exc}" - ) from exc + raise ValueError(f"{_provider_label(provider)} 资源 SDK 参数不匹配: {exc}") from exc if _is_duplicate_argument_error(exc): - raise ValueError( - f"{_provider_label(provider)} 资源 SDK 参数重复: {exc}" - ) from exc + raise ValueError(f"{_provider_label(provider)} 资源 SDK 参数重复: {exc}") from exc if provider_call_is_valid and _is_missing_required_argument_error(exc): - raise ValueError( - f"{_provider_label(provider)} 资源 SDK 参数不匹配: {exc}" - ) from exc + raise ValueError(f"{_provider_label(provider)} 资源 SDK 参数不匹配: {exc}") from exc raise setattr(wrapper, "_resource_guarded", True) # noqa: B010 return wrapper -def _is_provider_resource_method( - name: str, method: Any -) -> bool: +def _is_provider_resource_method(name: str, method: Any) -> bool: if name.startswith("_") or name in _RESOURCE_GUARD_EXCLUDED: return False if not callable(method): @@ -1685,6 +1663,7 @@ async def _await_resource_close(result: Awaitable[Any]) -> None: """将任意可等待关闭结果包装为 asyncio.run 可接受的协程。""" await result + class LargeLanguageModel(ABC): """语言模型抽象基类""" @@ -1762,7 +1741,9 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C request = request.copy_with(stream=False) invoke_kwargs = request.to_invoke_kwargs() accepted = inspect.signature(self.invoke).parameters - if not any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in accepted.values()): + if not any( + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in accepted.values() + ): invoke_kwargs = {key: value for key, value in invoke_kwargs.items() if key in accepted} text = "".join(self.invoke(**invoke_kwargs)) return CompletionResult(text=text) @@ -1776,30 +1757,40 @@ async def acomplete( chunks: list[str] = [] invoke_kwargs = request.to_invoke_kwargs() accepted = inspect.signature(self.ainvoke).parameters - if not any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in accepted.values()): + if not any( + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in accepted.values() + ): invoke_kwargs = {key: value for key, value in invoke_kwargs.items() if key in accepted} async for chunk in self.ainvoke(**invoke_kwargs): chunks.append(chunk) return CompletionResult(text="".join(chunks)) - def stream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> Generator[StreamEvent, None, None]: + def stream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> Generator[StreamEvent, None, None]: """以统一事件形式读取流;旧 Provider 默认包装文本流。""" request = coerce_completion_request(request, kwargs, "stream_events") request = request.copy_with(stream=True) invoke_kwargs = request.to_invoke_kwargs() accepted = inspect.signature(self.invoke).parameters - if not any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in accepted.values()): + if not any( + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in accepted.values() + ): invoke_kwargs = {key: value for key, value in invoke_kwargs.items() if key in accepted} for chunk in self.invoke(**invoke_kwargs): yield StreamEvent(type="text_delta", text=chunk) - async def astream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> AsyncGenerator[StreamEvent, None]: + async def astream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> AsyncGenerator[StreamEvent, None]: """异步统一事件流;旧 Provider 默认包装文本流。""" request = coerce_completion_request(request, kwargs, "astream_events") request = request.copy_with(stream=True) invoke_kwargs = request.to_invoke_kwargs() accepted = inspect.signature(self.ainvoke).parameters - if not any(parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in accepted.values()): + if not any( + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in accepted.values() + ): invoke_kwargs = {key: value for key, value in invoke_kwargs.items() if key in accepted} async for chunk in self.ainvoke(**invoke_kwargs): yield StreamEvent(type="text_delta", text=chunk) @@ -1812,6 +1803,7 @@ async def aclose(self) -> None: """释放 Provider 持有的异步 SDK 客户端。""" return + class TextEmbeddingModel(ABC): """文本向量化模型抽象基类""" @@ -1844,6 +1836,7 @@ async def aclose(self) -> None: """释放向量模型持有的异步资源;默认实现为空操作。""" return + class RerankModel(ABC): """Rerank模型抽象基类""" @@ -1852,7 +1845,9 @@ def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int """同步对文档列表进行重排序。""" @abstractmethod - async def arerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int], list[float]]: + async def arerank( + self, query: str, documents: list[str], top_n: int + ) -> tuple[list[int], list[float]]: """异步对文档列表进行重排序。""" def close(self) -> None: diff --git a/src/providers/anthropic.py b/src/providers/anthropic.py index 29c4469..187d60c 100644 --- a/src/providers/anthropic.py +++ b/src/providers/anthropic.py @@ -43,6 +43,7 @@ logger = get_module_logger(__name__) + class AnthropicProvider(LargeLanguageModel): """ Anthropic模型提供商,处理Claude系列模型。 @@ -51,8 +52,18 @@ class AnthropicProvider(LargeLanguageModel): capabilities = frozenset( { - "chat", "stream", "messages", "multimodal", "tools", "structured_output", - "usage", "token_count", "batches", "files", "models", "parse", + "chat", + "stream", + "messages", + "multimodal", + "tools", + "structured_output", + "usage", + "token_count", + "batches", + "files", + "models", + "parse", } ) @@ -89,11 +100,11 @@ def __init__(self, model_name: str, options: dict[str, Any] | None = None): self._options = validate_secret_free_options(options, "Anthropic") settings = get_settings() self._api_key = settings.anthropic_api_key - + if not self._api_key: logger.error("Anthropic API Key 未设置。") raise ValueError("ANTHROPIC_API_KEY is required for AnthropicProvider") - + self._client: anthropic.Anthropic | None = None self._aclient: anthropic.AsyncAnthropic | None = None logger.info(f"初始化 AnthropicProvider,模型: {model_name}") @@ -187,9 +198,7 @@ def _normalize_sampling_value(self, name: str, value: Any) -> Any: # backwards-compatible server value. Omitting either avoids a # request field that modern Claude models no longer use. return None - raise ValueError( - "当前 Claude 模型不支持该 temperature;请省略该字段或使用 1.0。" - ) + raise ValueError("当前 Claude 模型不支持该 temperature;请省略该字段或使用 1.0。") if name == "top_p": try: numeric = float(value) @@ -261,17 +270,19 @@ def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | # Anthropic 的 server_* 工具不是 function schema,原样传递其 # 专属字段,避免统一 OpenAI schema 破坏服务端工具配置。 if isinstance(tool_type, str) and ( - tool_type.startswith("server_") or tool_type in { - "computer_20250124", "bash_20250124", "text_editor_20250124", + tool_type.startswith("server_") + or tool_type + in { + "computer_20250124", + "bash_20250124", + "text_editor_20250124", } ): converted.append(dict(tool)) continue function = tool.get("function", tool) if not isinstance(function, Mapping): - raise ValueError( - f"Anthropic 工具定义[{index}].function 必须是对象。" - ) + raise ValueError(f"Anthropic 工具定义[{index}].function 必须是对象。") name = function.get("name") if not isinstance(name, str) or not name.strip(): raise ValueError(f"Anthropic 工具定义[{index}] 缺少 function.name。") @@ -279,9 +290,7 @@ def _convert_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]] | if description is None: description = "" if not isinstance(description, str): - raise ValueError( - f"Anthropic 工具定义[{index}].description 必须是字符串。" - ) + raise ValueError(f"Anthropic 工具定义[{index}].description 必须是字符串。") input_schema = function.get( "parameters", function.get("input_schema", {"type": "object", "properties": {}}), @@ -333,9 +342,7 @@ def _convert_tool_choice(tool_choice: Any) -> Any: name = choice.get("name") if isinstance(name, str) and name.strip(): return {"type": "tool", "name": name} - raise ValueError( - "Anthropic tool_choice 必须是 auto、any、none、tool 或 function 对象。" - ) + raise ValueError("Anthropic tool_choice 必须是 auto、any、none、tool 或 function 对象。") @staticmethod def _convert_content(content: Any) -> Any: @@ -364,7 +371,11 @@ def _convert_content(content: Any) -> Any: header, data = image_url.split(",", 1) header_parts = header.split(";", 1) media_type = header_parts[0].removeprefix("data:") - if not media_type or len(header_parts) == 1 or header_parts[1].lower() != "base64": + if ( + not media_type + or len(header_parts) == 1 + or header_parts[1].lower() != "base64" + ): raise ValueError( "Anthropic 图片 data URI 必须包含 MIME 类型和 base64 标记。" ) @@ -383,9 +394,7 @@ def _convert_content(content: Any) -> Any: } ) else: - converted.append( - {"type": "image", "source": {"type": "url", "url": image_url}} - ) + converted.append({"type": "image", "source": {"type": "url", "url": image_url}}) elif part_type == "tool_result": converted.append(part) else: @@ -430,12 +439,14 @@ def flush_tool_results() -> None: if role == "assistant" and message.get("tool_calls"): blocks: list[Any] = [] if content: - blocks.extend(cls._convert_content(content) if isinstance(content, list) else [{"type": "text", "text": content}]) + blocks.extend( + cls._convert_content(content) + if isinstance(content, list) + else [{"type": "text", "text": content}] + ) for index, call in enumerate(message.get("tool_calls", [])): if not isinstance(call, Mapping): - raise ValueError( - f"Anthropic assistant tool_calls[{index}] 必须是对象。" - ) + raise ValueError(f"Anthropic assistant tool_calls[{index}] 必须是对象。") function = call.get("function", call) if not isinstance(function, Mapping): raise ValueError( @@ -444,9 +455,7 @@ def flush_tool_results() -> None: call_id = call.get("id") name = function.get("name") if not isinstance(call_id, str) or not call_id.strip(): - raise ValueError( - f"Anthropic assistant tool_calls[{index}] 缺少 id。" - ) + raise ValueError(f"Anthropic assistant tool_calls[{index}] 缺少 id。") if not isinstance(name, str) or not name.strip(): raise ValueError( f"Anthropic assistant tool_calls[{index}] 缺少 function.name。" @@ -463,14 +472,21 @@ def flush_tool_results() -> None: raise ValueError( f"Anthropic assistant tool_calls[{index}] 的 arguments 必须是 JSON 对象。" ) - blocks.append({ - "type": "tool_use", - "id": call_id, - "name": name, - "input": dict(arguments), - }) + blocks.append( + { + "type": "tool_use", + "id": call_id, + "name": name, + "input": dict(arguments), + } + ) content = blocks - converted.append({"role": "assistant" if role == "assistant" else "user", "content": cls._convert_content(content)}) + converted.append( + { + "role": "assistant" if role == "assistant" else "user", + "content": cls._convert_content(content), + } + ) flush_tool_results() return converted @@ -578,9 +594,7 @@ def _build_message_params( params["service_tier"] = service_tier if user is not None: if for_parse and not beta: - raise ValueError( - "Anthropic Messages parse 不支持 user;请改用 beta_parse。" - ) + raise ValueError("Anthropic Messages parse 不支持 user;请改用 beta_parse。") # The SDK translates this argument into the required header. Add # the beta opt-in explicitly because ordinary Messages calls do # not otherwise advertise user-profile support. @@ -604,8 +618,12 @@ def _build_message_params( # after initialization. Normal construction rejects these fields at # the configuration boundary, but they must never reach messages.create. client_only = { - "base_url", "timeout", "max_retries", "default_headers", - "default_query", "http_client", + "base_url", + "timeout", + "max_retries", + "default_headers", + "default_query", + "http_client", } options = { key: value @@ -663,8 +681,7 @@ def _build_message_params( raise ValueError(f"Anthropic extra_body 不允许覆盖配置字段: {', '.join(overlap)}") sampling_options = { - key: options.pop(key, None) - for key in ("temperature", "top_p", "top_k") + key: options.pop(key, None) for key in ("temperature", "top_p", "top_k") } sampling_values = { "temperature": ( @@ -694,10 +711,17 @@ def _build_message_params( if body: reserved = set(params).intersection(body) if reserved: - raise ValueError(f"Anthropic extra_body 不允许覆盖请求字段: {', '.join(sorted(reserved))}") + raise ValueError( + f"Anthropic extra_body 不允许覆盖请求字段: {', '.join(sorted(reserved))}" + ) params["extra_body"] = body for key in ( - "model", "messages", "system", "max_tokens", "tool_choice", "tools", + "model", + "messages", + "system", + "max_tokens", + "tool_choice", + "tools", ): options.pop(key, None) options.pop("max_tokens", None) @@ -706,15 +730,28 @@ def _build_message_params( "user": "user_profile_id", } allowed_options = { - "metadata", "cache_control", "container", "inference_geo", "thinking", - "service_tier", "extra_headers", "extra_query", "output_config", - "stop_sequences", "user_profile_id", + "metadata", + "cache_control", + "container", + "inference_geo", + "thinking", + "service_tier", + "extra_headers", + "extra_query", + "output_config", + "stop_sequences", + "user_profile_id", } if beta: allowed_options.update( { - "context_management", "mcp_servers", "speed", "betas", - "diagnostics", "fallback_credit_token", "fallbacks", + "context_management", + "mcp_servers", + "speed", + "betas", + "diagnostics", + "fallback_credit_token", + "fallbacks", } ) allowed_options.update(option_aliases) @@ -729,7 +766,9 @@ def _build_message_params( @classmethod def _extract_result(cls, response: Any) -> CompletionResult: blocks = field(response, "content", []) or [] - text = "".join(field(block, "text", "") for block in blocks if field(block, "type") == "text") + text = "".join( + field(block, "text", "") for block in blocks if field(block, "type") == "text" + ) reasoning = "".join( field(block, "thinking", "") for block in blocks @@ -756,7 +795,9 @@ def _extract_result(cls, response: Any) -> CompletionResult: ) if not refusal: stop_reason = field(response, "stop_reason") - stop_details = field(response, "stop_details") or field(response, "refusal_stop_details") + stop_details = field(response, "stop_details") or field( + response, "refusal_stop_details" + ) details_type = field(stop_details, "type") if stop_reason == "refusal" or details_type == "refusal": explanation = field(stop_details, "explanation") @@ -886,7 +927,11 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven if block_type == "tool_use": initial_input = field(block, "input", {}) if isinstance(initial_input, Mapping): - initial_arguments = json.dumps(initial_input, ensure_ascii=False, separators=(",", ":")) if initial_input else "" + initial_arguments = ( + json.dumps(initial_input, ensure_ascii=False, separators=(",", ":")) + if initial_input + else "" + ) else: initial_arguments = str(initial_input) if initial_input else "" return StreamEvent( @@ -913,9 +958,19 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven delta = field(event, "delta") delta_type = field(delta, "type") if delta_type == "text_delta": - return StreamEvent(type="text_delta", text=field(delta, "text", ""), response_id=response_id, raw=event) + return StreamEvent( + type="text_delta", + text=field(delta, "text", ""), + response_id=response_id, + raw=event, + ) if delta_type == "thinking_delta": - return StreamEvent(type="reasoning_delta", reasoning=field(delta, "thinking", ""), response_id=response_id, raw=event) + return StreamEvent( + type="reasoning_delta", + reasoning=field(delta, "thinking", ""), + response_id=response_id, + raw=event, + ) if delta_type in {"refusal_delta", "refusal"}: refusal = field(delta, "refusal") or field(delta, "text") or field(delta, "reason") return StreamEvent( @@ -998,24 +1053,58 @@ def _stream_error_message(event: Any) -> str: pending.append(value) return "Anthropic Messages 流返回错误" - def stream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> Generator[StreamEvent, None, None]: + def stream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> Generator[StreamEvent, None, None]: request = coerce_completion_request(request, kwargs, "Anthropic stream_events") request = request.copy_with(stream=True) params = self._build_message_params(**request.to_invoke_kwargs()) if not request.stream: result = self.complete(request) if result.text: - yield StreamEvent(type="text_delta", text=result.text, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="text_delta", + text=result.text, + response_id=result.response_id, + raw=result.raw, + ) if result.reasoning: - yield StreamEvent(type="reasoning_delta", reasoning=result.reasoning, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="reasoning_delta", + reasoning=result.reasoning, + response_id=result.response_id, + raw=result.raw, + ) if result.refusal: - yield StreamEvent(type="refusal_delta", refusal=result.refusal, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="refusal_delta", + refusal=result.refusal, + response_id=result.response_id, + raw=result.raw, + ) for call in result.tool_calls: - yield StreamEvent(type="tool_call_delta", tool_call=call, response_id=result.response_id, raw=result.raw) - yield StreamEvent(type="tool_call_completed", tool_call=call, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="tool_call_delta", + tool_call=call, + response_id=result.response_id, + raw=result.raw, + ) + yield StreamEvent( + type="tool_call_completed", + tool_call=call, + response_id=result.response_id, + raw=result.raw, + ) if result.usage: - yield StreamEvent(type="usage", usage=result.usage, response_id=result.response_id, raw=result.raw) - yield StreamEvent(type="finish", finish_reason=result.finish_reason, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="usage", usage=result.usage, response_id=result.response_id, raw=result.raw + ) + yield StreamEvent( + type="finish", + finish_reason=result.finish_reason, + response_id=result.response_id, + raw=result.raw, + ) return response_id: str | None = None finish_emitted = False @@ -1051,24 +1140,58 @@ def stream_events(self, request: CompletionRequest | None = None, **kwargs: Any) if not message_stop_seen: raise RuntimeError("Anthropic Messages 流在 message_stop 之前结束。") - async def astream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> AsyncGenerator[StreamEvent, None]: + async def astream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> AsyncGenerator[StreamEvent, None]: request = coerce_completion_request(request, kwargs, "Anthropic astream_events") request = request.copy_with(stream=True) params = self._build_message_params(**request.to_invoke_kwargs()) if not request.stream: result = await self.acomplete(request) if result.text: - yield StreamEvent(type="text_delta", text=result.text, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="text_delta", + text=result.text, + response_id=result.response_id, + raw=result.raw, + ) if result.reasoning: - yield StreamEvent(type="reasoning_delta", reasoning=result.reasoning, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="reasoning_delta", + reasoning=result.reasoning, + response_id=result.response_id, + raw=result.raw, + ) if result.refusal: - yield StreamEvent(type="refusal_delta", refusal=result.refusal, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="refusal_delta", + refusal=result.refusal, + response_id=result.response_id, + raw=result.raw, + ) for call in result.tool_calls: - yield StreamEvent(type="tool_call_delta", tool_call=call, response_id=result.response_id, raw=result.raw) - yield StreamEvent(type="tool_call_completed", tool_call=call, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="tool_call_delta", + tool_call=call, + response_id=result.response_id, + raw=result.raw, + ) + yield StreamEvent( + type="tool_call_completed", + tool_call=call, + response_id=result.response_id, + raw=result.raw, + ) if result.usage: - yield StreamEvent(type="usage", usage=result.usage, response_id=result.response_id, raw=result.raw) - yield StreamEvent(type="finish", finish_reason=result.finish_reason, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="usage", usage=result.usage, response_id=result.response_id, raw=result.raw + ) + yield StreamEvent( + type="finish", + finish_reason=result.finish_reason, + response_id=result.response_id, + raw=result.raw, + ) return response_id: str | None = None finish_emitted = False @@ -1112,7 +1235,9 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C response = retry_sync_call(lambda: self._get_client().messages.create(**params)) return self._extract_result(response) - async def acomplete(self, request: CompletionRequest | None = None, **kwargs: Any) -> CompletionResult: + async def acomplete( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> CompletionResult: """使用 AsyncAnthropic 聚合完整结果,保留工具、thinking 和 usage。""" request = coerce_completion_request(request, kwargs, "Anthropic acomplete") values = request.to_invoke_kwargs() @@ -1144,17 +1269,37 @@ def parse( def _parse_params(params: dict[str, Any], *, beta: bool) -> dict[str, Any]: """只保留当前 SDK 的 Messages.parse 参数。""" allowed = { - "model", "max_tokens", "messages", "metadata", "output_config", - "output_format", "service_tier", "stop_sequences", "system", - "thinking", "tool_choice", "tools", - "extra_headers", "extra_query", "extra_body", "timeout", + "model", + "max_tokens", + "messages", + "metadata", + "output_config", + "output_format", + "service_tier", + "stop_sequences", + "system", + "thinking", + "tool_choice", + "tools", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } if beta: allowed.update( { - "cache_control", "container", "context_management", "diagnostics", - "fallback_credit_token", "fallbacks", "inference_geo", "mcp_servers", - "speed", "betas", "user_profile_id", + "cache_control", + "container", + "context_management", + "diagnostics", + "fallback_credit_token", + "fallbacks", + "inference_geo", + "mcp_servers", + "speed", + "betas", + "user_profile_id", } ) reject_unsupported_kwargs( @@ -1167,29 +1312,36 @@ def _parse_params(params: dict[str, Any], *, beta: bool) -> dict[str, Any]: def _validate_token_count_request(request: CompletionRequest, *, beta: bool) -> None: """拒绝不会被当前 Messages count_tokens SDK 使用的请求字段。""" allowed = { - "prompt", "system_prompt", "messages", "tools", "tool_choice", - "cache_control", "response_format", "output_config", + "prompt", + "system_prompt", + "messages", + "tools", + "tool_choice", + "cache_control", + "response_format", + "output_config", # CompletionRequest defaults temperature for generation. It has no # effect on token counting, so accept the shared default without # forwarding it to the count_tokens endpoint. - "thinking", "user", "extra_body", "extra_headers", "extra_query", - "timeout", "stream", "temperature", + "thinking", + "user", + "extra_body", + "extra_headers", + "extra_query", + "timeout", + "stream", + "temperature", } if request.output_format is not None: mode = "Beta " if beta else "" raise ValueError( - f"Anthropic {mode}Messages token count 不支持 output_format;" - "请使用 output_config。" + f"Anthropic {mode}Messages token count 不支持 output_format;请使用 output_config。" ) if beta: allowed.update({"context_management", "mcp_servers", "speed", "betas"}) reject_unsupported_kwargs( "Anthropic Beta Messages token count" if beta else "Anthropic Messages token count", - { - key: value - for key, value in request.to_invoke_kwargs().items() - if key not in allowed - }, + {key: value for key, value in request.to_invoke_kwargs().items() if key not in allowed}, ) def count_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) -> int: @@ -1253,7 +1405,9 @@ def beta_create(self, request: CompletionRequest | None = None, **kwargs: Any) - params = self._build_message_params(**values, beta=True, stream=False) return self._get_client().beta.messages.create(**params) - def beta_parse(self, output_format: Any, request: CompletionRequest | None = None, **kwargs: Any) -> Any: + def beta_parse( + self, output_format: Any, request: CompletionRequest | None = None, **kwargs: Any + ) -> Any: if output_format is None: raise ValueError("Anthropic Beta parse 必须提供 output_format。") request = coerce_completion_request(request, kwargs, "Anthropic beta_parse") @@ -1329,23 +1483,15 @@ def beta_stream(self, request: CompletionRequest | None = None, **kwargs: Any) - values = request.to_invoke_kwargs() values.pop("stream", None) params = self._build_message_params(**values, beta=True, stream=True) - return retry_sync_stream_context( - lambda: self._get_client().beta.messages.stream(**params) - ) + return retry_sync_stream_context(lambda: self._get_client().beta.messages.stream(**params)) def beta_tool_runner( self, tools: Any, request: CompletionRequest | None = None, **kwargs: Any ) -> Any: """创建 Anthropic Beta 工具运行器,保留 SDK 原生 runner 对象。""" if "compaction_control" in kwargs: - raise ValueError( - "Anthropic Beta tool_runner 当前 SDK 不支持 compaction_control。" - ) - runner_options = { - key: kwargs.pop(key) - for key in ("max_iterations",) - if key in kwargs - } + raise ValueError("Anthropic Beta tool_runner 当前 SDK 不支持 compaction_control。") + runner_options = {key: kwargs.pop(key) for key in ("max_iterations",) if key in kwargs} request = coerce_completion_request(request, kwargs, "Anthropic beta_tool_runner") values = request.to_invoke_kwargs() values.pop("stream", None) @@ -1410,7 +1556,9 @@ def retrieve_model(self, model_id: str, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "模型") return self._get_client().models.retrieve(model_id, **kwargs) - async def async_count_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) -> int: + async def async_count_tokens( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> int: request = coerce_completion_request(request, kwargs, "Anthropic async_count_tokens") self._validate_token_count_request(request, beta=False) normalized = normalize_messages(request.prompt, request.system_prompt, request.messages) @@ -1452,13 +1600,17 @@ async def async_count_tokens(self, request: CompletionRequest | None = None, **k params["extra_query"] = request.extra_query if request.timeout is not None: params["timeout"] = request.timeout - response = await self._resolve_async_result(self._get_aclient().messages.count_tokens(**params)) + response = await self._resolve_async_result( + self._get_aclient().messages.count_tokens(**params) + ) value = field(response, "input_tokens") if value is None: raise RuntimeError("Anthropic token count 响应缺少 input_tokens。") return int(value) - async def async_beta_create(self, request: CompletionRequest | None = None, **kwargs: Any) -> Any: + async def async_beta_create( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> Any: request = coerce_completion_request(request, kwargs, "Anthropic async_beta_create") if request.output_format is not None: raise ValueError( @@ -1469,7 +1621,9 @@ async def async_beta_create(self, request: CompletionRequest | None = None, **kw params = self._build_message_params(**values, beta=True, stream=False) return await self._resolve_async_result(self._get_aclient().beta.messages.create(**params)) - async def async_beta_parse(self, output_format: Any, request: CompletionRequest | None = None, **kwargs: Any) -> Any: + async def async_beta_parse( + self, output_format: Any, request: CompletionRequest | None = None, **kwargs: Any + ) -> Any: if output_format is None: raise ValueError("Anthropic Beta parse 必须提供 output_format。") request = coerce_completion_request(request, kwargs, "Anthropic async_beta_parse") @@ -1484,7 +1638,9 @@ async def async_beta_parse(self, output_format: Any, request: CompletionRequest self._get_aclient().beta.messages.parse(output_format=output_format, **params) ) - async def async_beta_count_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) -> int: + async def async_beta_count_tokens( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> int: request = coerce_completion_request(request, kwargs, "Anthropic async_beta_count_tokens") self._validate_token_count_request(request, beta=True) normalized = normalize_messages(request.prompt, request.system_prompt, request.messages) @@ -1557,14 +1713,8 @@ def async_beta_tool_runner( ) -> Any: """创建 AsyncAnthropic Beta 工具运行器。""" if "compaction_control" in kwargs: - raise ValueError( - "Anthropic Beta tool_runner 当前 SDK 不支持 compaction_control。" - ) - runner_options = { - key: kwargs.pop(key) - for key in ("max_iterations",) - if key in kwargs - } + raise ValueError("Anthropic Beta tool_runner 当前 SDK 不支持 compaction_control。") + runner_options = {key: kwargs.pop(key) for key in ("max_iterations",) if key in kwargs} request = coerce_completion_request(request, kwargs, "Anthropic async_beta_tool_runner") values = request.to_invoke_kwargs() values.pop("stream", None) @@ -1653,15 +1803,21 @@ async def async_create_batch(self, requests: Any, **kwargs: Any) -> Any: async def async_retrieve_batch(self, batch_id: str, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "批处理") - return await self._resolve_async_result(self._get_aclient().messages.batches.retrieve(batch_id, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().messages.batches.retrieve(batch_id, **kwargs) + ) async def async_batch_results(self, batch_id: str, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "批处理") - return await self._resolve_async_result(self._get_aclient().messages.batches.results(batch_id, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().messages.batches.results(batch_id, **kwargs) + ) async def async_cancel_batch(self, batch_id: str, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "批处理") - return await self._resolve_async_result(self._get_aclient().messages.batches.cancel(batch_id, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().messages.batches.cancel(batch_id, **kwargs) + ) async def async_list_batches(self, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "批处理") @@ -1669,11 +1825,15 @@ async def async_list_batches(self, **kwargs: Any) -> Any: async def async_delete_batch(self, batch_id: str, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "批处理") - return await self._resolve_async_result(self._get_aclient().messages.batches.delete(batch_id, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().messages.batches.delete(batch_id, **kwargs) + ) async def async_upload_file(self, file: Any, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "文件") - return await self._resolve_async_result(self._get_aclient().files.upload(file=file, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().files.upload(file=file, **kwargs) + ) async def async_list_files(self, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "文件") @@ -1681,11 +1841,15 @@ async def async_list_files(self, **kwargs: Any) -> Any: async def async_retrieve_file(self, file_id: str, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "文件") - return await self._resolve_async_result(self._get_aclient().files.retrieve_metadata(file_id, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().files.retrieve_metadata(file_id, **kwargs) + ) async def async_download_file(self, file_id: str, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "文件") - return await self._resolve_async_result(self._get_aclient().files.download(file_id, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().files.download(file_id, **kwargs) + ) async def async_delete_file(self, file_id: str, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "文件") @@ -1697,7 +1861,9 @@ async def async_list_models(self, **kwargs: Any) -> Any: async def async_retrieve_model(self, model_id: str, **kwargs: Any) -> Any: kwargs = self._safe_resource_kwargs(kwargs, "模型") - return await self._resolve_async_result(self._get_aclient().models.retrieve(model_id, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().models.retrieve(model_id, **kwargs) + ) def invoke( self, @@ -1830,9 +1996,7 @@ async def ainvoke( async for text in self._aiter_invoke_stream_text(response): yield text else: - response = await retry_async_call( - lambda: aclient.messages.create(**params) - ) + response = await retry_async_call(lambda: aclient.messages.create(**params)) result = self._extract_result(response) if result.refusal: raise RuntimeError(f"Anthropic 请求被拒绝: {result.refusal}") diff --git a/src/providers/deepseek.py b/src/providers/deepseek.py index 2829c45..e707cf1 100644 --- a/src/providers/deepseek.py +++ b/src/providers/deepseek.py @@ -9,8 +9,15 @@ class DeepSeekProvider(OpenAICompatibleProvider): 通过继承OpenAICompatibleProvider来复用与OpenAI API兼容的逻辑。 """ - def __init__(self, model_name: str, protocol: str = "chat_completions", options: dict[str, Any] | None = None): - super().__init__(model_name=model_name, provider="deepseek", protocol=protocol, options=options) + def __init__( + self, + model_name: str, + protocol: str = "chat_completions", + options: dict[str, Any] | None = None, + ): + super().__init__( + model_name=model_name, provider="deepseek", protocol=protocol, options=options + ) def _chat_max_tokens_key(self) -> str: """DeepSeek Chat Completions 仍使用旧版 max_tokens 字段。""" diff --git a/src/providers/factory.py b/src/providers/factory.py index 501de05..7ee7316 100644 --- a/src/providers/factory.py +++ b/src/providers/factory.py @@ -19,7 +19,8 @@ from src.utils.log_manager import get_module_logger # 导入日志管理器 from src.utils.security import redact_sensitive_text, validate_secret_free_options -logger = get_module_logger(__name__) # 获取当前模块的日志器 +logger = get_module_logger(__name__) # 获取当前模块的日志器 + class ModelProviderFactory: """模型提供商工厂""" @@ -156,7 +157,6 @@ class ModelProviderFactory: "integration": "local", "capabilities": {"embedding"}, }, - # Rerank Providers "jina": { "module": "src.providers.jina", @@ -186,7 +186,9 @@ def _get_provider_class(provider_name: str) -> type: try: module = importlib.import_module(provider_info["module"]) ProviderClass = getattr(module, provider_info["class"]) - logger.debug(f"成功加载提供商类: {provider_info['class']} from {provider_info['module']}") + logger.debug( + f"成功加载提供商类: {provider_info['class']} from {provider_info['module']}" + ) return ProviderClass except ImportError as e: logger.error( @@ -255,8 +257,7 @@ def _create_provider( provider_class = ModelProviderFactory._get_provider_class(resolved_provider_name) if not issubclass(provider_class, expected_type): raise TypeError( - f"提供商 {provider_name} 不支持 {role} 能力," - f"需要实现 {expected_type.__name__}。" + f"提供商 {provider_name} 不支持 {role} 能力,需要实现 {expected_type.__name__}。" ) provider_options = dict(options or {}) parameters = inspect.signature(provider_class).parameters @@ -279,8 +280,7 @@ def _create_provider( # protocol 构造参数;OpenAI 兼容 provider 才需要显式选择协议。 parameters = inspect.signature(provider_class).parameters if "protocol" not in parameters and not any( - parameter.kind is inspect.Parameter.VAR_KEYWORD - for parameter in parameters.values() + parameter.kind is inspect.Parameter.VAR_KEYWORD for parameter in parameters.values() ): if ( protocol_value is not None @@ -316,9 +316,7 @@ def _runtime_verified_protocols( # ``http_options`` is a supported google-genai SDK container. Keep # the general options boundary strict while allowing only this # explicitly supported container and its safe trace headers. - remaining = { - key: value for key, value in options.items() if key != "http_options" - } + remaining = {key: value for key, value in options.items() if key != "http_options"} validated = validate_secret_free_options(remaining, "Provider") validated["http_options"] = validate_secret_free_options( {"http_options": options["http_options"]}, @@ -450,7 +448,8 @@ def protocol_status( ( candidate for candidate in candidates - if candidate in cls._provider_map.get( + if candidate + in cls._provider_map.get( cls._resolve_provider_name(provider_name, candidate), {} ).get("capabilities", set()) ), @@ -516,7 +515,12 @@ def get_llm_provider( try: instance = ModelProviderFactory._create_provider( - provider_name, model_name, "llm", LargeLanguageModel, config.protocol, config.options + provider_name, + model_name, + "llm", + LargeLanguageModel, + config.protocol, + config.options, ) logger.info(f"成功获取LLM提供商实例: {provider_name} ({model_name})") return instance @@ -544,7 +548,9 @@ def get_embedding_provider( try: if config.protocol is not None: - raise ValueError("Embedding 配置不支持 protocol;协议只能配置在 llm_configurations 中。") + raise ValueError( + "Embedding 配置不支持 protocol;协议只能配置在 llm_configurations 中。" + ) instance = ModelProviderFactory._create_provider( provider_name, model_name, "embedding", TextEmbeddingModel, options=config.options ) @@ -574,7 +580,9 @@ def get_rerank_provider( try: if config.protocol is not None: - raise ValueError("Rerank 配置不支持 protocol;协议只能配置在 llm_configurations 中。") + raise ValueError( + "Rerank 配置不支持 protocol;协议只能配置在 llm_configurations 中。" + ) instance = ModelProviderFactory._create_provider( provider_name, model_name, "rerank", RerankModel, options=config.options ) diff --git a/src/providers/google.py b/src/providers/google.py index 4803f54..6616f77 100644 --- a/src/providers/google.py +++ b/src/providers/google.py @@ -61,11 +61,32 @@ class GoogleProvider(LargeLanguageModel, TextEmbeddingModel): capabilities = frozenset( { - "chat", "stream", "messages", "multimodal", "tools", "structured_output", - "usage", "token_count", "embedding", "files", "batches", "cache", - "models", "tuning", "images", "videos", "file_search", - "interactions", "agents", "webhooks", "environments", "triggers", "live", - "auth_tokens", "operations", "chats", + "chat", + "stream", + "messages", + "multimodal", + "tools", + "structured_output", + "usage", + "token_count", + "embedding", + "files", + "batches", + "cache", + "models", + "tuning", + "images", + "videos", + "file_search", + "interactions", + "agents", + "webhooks", + "environments", + "triggers", + "live", + "auth_tokens", + "operations", + "chats", } ) @@ -139,16 +160,45 @@ class GoogleProvider(LargeLanguageModel, TextEmbeddingModel): _HTTP_OPTIONS_SENSITIVE_EXACT = frozenset( { - "apikey", "xapikey", "accesskey", "secretkey", "token", "password", - "passwd", "authorization", "auth", "bearer", "accesstoken", - "xaccesstoken", "authtoken", "xauthtoken", "authkey", "credential", - "credentials", "cookie", "setcookie", "proxyauthorization", "secret", - "privatekey", "httpclient", "httpxclient", "httpxasyncclient", - "aiohttpclient", "clientargs", "asyncclientargs", "baseurl", - "baseurlresourcescope", "proxy", "proxies", "transport", "verify", - "cert", "mounts", + "apikey", + "xapikey", + "accesskey", + "secretkey", + "token", + "password", + "passwd", + "authorization", + "auth", + "bearer", + "accesstoken", + "xaccesstoken", + "authtoken", + "xauthtoken", + "authkey", + "credential", + "credentials", + "cookie", + "setcookie", + "proxyauthorization", + "secret", + "privatekey", + "httpclient", + "httpxclient", + "httpxasyncclient", + "aiohttpclient", + "clientargs", + "asyncclientargs", + "baseurl", + "baseurlresourcescope", + "proxy", + "proxies", + "transport", + "verify", + "cert", + "mounts", } ) + def __init__(self, model_name: str, options: dict[str, Any] | None = None): self._model_name = model_name if options is not None and not isinstance(options, Mapping): @@ -198,7 +248,7 @@ def _get_client(self) -> Any: "GOOGLE_API_KEY is required for GoogleProvider unless options.credentials is provided " "or Vertex/Enterprise mode enables Application Default Credentials" ) - + logger.debug("正在初始化 google-genai Client") client_options = { key: value @@ -211,13 +261,9 @@ def _get_client(self) -> Any: if "enterprise" not in client_options and "vertexai" not in client_options: configured_flags = self._configured_environment_vertex_flags(settings) if "GOOGLE_GENAI_USE_ENTERPRISE" in configured_flags: - client_options["enterprise"] = configured_flags[ - "GOOGLE_GENAI_USE_ENTERPRISE" - ] + client_options["enterprise"] = configured_flags["GOOGLE_GENAI_USE_ENTERPRISE"] elif "GOOGLE_GENAI_USE_VERTEXAI" in configured_flags: - client_options["vertexai"] = configured_flags[ - "GOOGLE_GENAI_USE_VERTEXAI" - ] + client_options["vertexai"] = configured_flags["GOOGLE_GENAI_USE_VERTEXAI"] if "debug_config" in client_options: client_options["debug_config"] = self._coerce_debug_config( client_options["debug_config"] @@ -225,7 +271,11 @@ def _get_client(self) -> Any: if credentials is not None: if "credentials" not in client_options: client_options["credentials"] = credentials - if vertexai is True and "enterprise" not in client_options and "vertexai" not in client_options: + if ( + vertexai is True + and "enterprise" not in client_options + and "vertexai" not in client_options + ): # google-genai treats explicit credentials as Vertex ADC # credentials only when the Vertex transport is selected. # Make that implication explicit instead of letting the SDK @@ -307,9 +357,7 @@ def _validate_options(cls, options: Mapping[str, Any]) -> None: raise ValueError("Google options 必须是对象。") unknown = sorted(set(options).difference(cls._OPTION_KEYS)) if unknown: - raise ValueError( - "Google options 包含不支持的字段: " + ", ".join(unknown) - ) + raise ValueError("Google options 包含不支持的字段: " + ", ".join(unknown)) if "extra_body" in options: validate_secret_free_payload( options["extra_body"], @@ -411,9 +459,7 @@ def _validate_http_options(cls, value: Any) -> None: or float(timeout) <= 0 or int(float(timeout)) != float(timeout) ): - raise ValueError( - "Google options.http_options.timeout 必须是正整数毫秒。" - ) + raise ValueError("Google options.http_options.timeout 必须是正整数毫秒。") found: list[str] = [] @@ -427,9 +473,16 @@ def is_sensitive_key(key: Any) -> bool: return any( normalized.endswith(marker) for marker in ( - "apikey", "accesskey", "accesstoken", "authtoken", - "secret", "secretkey", "clientsecret", "password", - "credential", "cookie", + "apikey", + "accesskey", + "accesstoken", + "authtoken", + "secret", + "secretkey", + "clientsecret", + "password", + "credential", + "cookie", ) ) @@ -465,7 +518,9 @@ def visit(item: Any, path: str = "") -> None: parsed = urlsplit(item) except ValueError: parsed = None - if parsed is not None and (parsed.username is not None or parsed.password is not None): + if parsed is not None and ( + parsed.username is not None or parsed.password is not None + ): found.append(f"{path} userinfo") visit(payload) @@ -502,9 +557,7 @@ def _validate_resource_config(cls, config: Any) -> None: def visit(value: Any) -> None: if isinstance(value, Mapping): for key, nested in value.items(): - normalized = "".join( - char for char in str(key).lower() if char.isalnum() - ) + normalized = "".join(char for char in str(key).lower() if char.isalnum()) if normalized == "httpoptions": cls._validate_http_options(nested) continue @@ -527,9 +580,7 @@ def _coerce_debug_config(value: Any) -> Any: allowed = {"client_mode", "replays_directory", "replay_id"} unknown = sorted(set(value).difference(allowed)) if unknown: - raise ValueError( - "Google debug_config 包含不支持的字段: " + ", ".join(unknown) - ) + raise ValueError("Google debug_config 包含不支持的字段: " + ", ".join(unknown)) try: from google.genai.client import DebugConfig except ImportError as exc: @@ -607,9 +658,7 @@ def _effective_vertex_mode(self, client: Any) -> bool | None: def _vertex_embed_content_only(self) -> bool: """判断当前模型是否只能通过 Vertex 的单内容 embedContent 调用。""" model = self._model_name.lower() - return ( - "gemini" in model - ) or "maas" in model + return ("gemini" in model) or "maas" in model @staticmethod def _validate_token_count_fields(request: CompletionRequest) -> None: @@ -617,19 +666,58 @@ def _validate_token_count_fields(request: CompletionRequest) -> None: unsupported = { key: value for key, value in request.to_invoke_kwargs().items() - if key in { - "max_tokens", "top_p", "top_k", "frequency_penalty", - "presence_penalty", "n", "logit_bias", "logprobs", - "top_logprobs", "modalities", "audio", "prediction", - "web_search_options", "seed", "stop", "response_format", - "tool_choice", "reasoning", "thinking", "store", "background", - "parallel_tool_calls", "max_tool_calls", "conversation", "session", - "context_management", "caching", "expire_at", "prompt_cache_options", - "safety_identifier", "moderation", "verbosity", "prompt_cache_key", - "prompt_cache_retention", "service_tier", "truncation", "stream_options", - "cache_control", "container", "inference_geo", "mcp_servers", - "output_config", "output_format", "speed", "betas", "diagnostics", - "fallback_credit_token", "fallbacks", "user", "personality", + if key + in { + "max_tokens", + "top_p", + "top_k", + "frequency_penalty", + "presence_penalty", + "n", + "logit_bias", + "logprobs", + "top_logprobs", + "modalities", + "audio", + "prediction", + "web_search_options", + "seed", + "stop", + "response_format", + "tool_choice", + "reasoning", + "thinking", + "store", + "background", + "parallel_tool_calls", + "max_tool_calls", + "conversation", + "session", + "context_management", + "caching", + "expire_at", + "prompt_cache_options", + "safety_identifier", + "moderation", + "verbosity", + "prompt_cache_key", + "prompt_cache_retention", + "service_tier", + "truncation", + "stream_options", + "cache_control", + "container", + "inference_geo", + "mcp_servers", + "output_config", + "output_format", + "speed", + "betas", + "diagnostics", + "fallback_credit_token", + "fallbacks", + "user", + "personality", } and value is not None } @@ -641,18 +729,59 @@ def _validate_compute_token_fields(request: CompletionRequest) -> None: unsupported = { key: value for key, value in request.to_invoke_kwargs().items() - if key in { - "max_tokens", "top_p", "top_k", "frequency_penalty", - "presence_penalty", "n", "logit_bias", "logprobs", "top_logprobs", - "modalities", "audio", "prediction", "web_search_options", "seed", - "stop", "response_format", "tool_choice", "reasoning", "thinking", - "store", "background", "parallel_tool_calls", "max_tool_calls", - "conversation", "session", "context_management", "caching", "expire_at", - "prompt_cache_options", "safety_identifier", "moderation", "verbosity", - "prompt_cache_key", "prompt_cache_retention", "service_tier", "truncation", - "stream_options", "cache_control", "container", "inference_geo", "mcp_servers", - "output_config", "output_format", "speed", "betas", "diagnostics", - "fallback_credit_token", "fallbacks", "user", "personality", "tools", + if key + in { + "max_tokens", + "top_p", + "top_k", + "frequency_penalty", + "presence_penalty", + "n", + "logit_bias", + "logprobs", + "top_logprobs", + "modalities", + "audio", + "prediction", + "web_search_options", + "seed", + "stop", + "response_format", + "tool_choice", + "reasoning", + "thinking", + "store", + "background", + "parallel_tool_calls", + "max_tool_calls", + "conversation", + "session", + "context_management", + "caching", + "expire_at", + "prompt_cache_options", + "safety_identifier", + "moderation", + "verbosity", + "prompt_cache_key", + "prompt_cache_retention", + "service_tier", + "truncation", + "stream_options", + "cache_control", + "container", + "inference_geo", + "mcp_servers", + "output_config", + "output_format", + "speed", + "betas", + "diagnostics", + "fallback_credit_token", + "fallbacks", + "user", + "personality", + "tools", } and value is not None } @@ -669,8 +798,13 @@ def _token_count_system_prompt(request: CompletionRequest) -> str | None: def _embedding_options(self) -> dict[str, Any]: supported = { - "task_type", "output_dimensionality", "title", "mime_type", - "auto_truncate", "document_ocr", "audio_track_extraction", + "task_type", + "output_dimensionality", + "title", + "mime_type", + "auto_truncate", + "document_ocr", + "audio_track_extraction", } return {key: value for key, value in self._options.items() if key in supported} @@ -706,20 +840,46 @@ def _resource_config_kwargs( @staticmethod def _validate_embedding_kwargs(kwargs: dict[str, Any]) -> None: allowed = { - "task_type", "output_dimensionality", "title", "mime_type", - "auto_truncate", "document_ocr", "audio_track_extraction", + "task_type", + "output_dimensionality", + "title", + "mime_type", + "auto_truncate", + "document_ocr", + "audio_track_extraction", } - unsupported = sorted(key for key, value in kwargs.items() if value is not None and key not in allowed) + unsupported = sorted( + key for key, value in kwargs.items() if value is not None and key not in allowed + ) if unsupported: raise ValueError(f"Google Embedding 不支持请求参数: {', '.join(unsupported)}") @staticmethod def _validate_request(request: CompletionRequest) -> None: supported = { - "prompt", "system_prompt", "messages", "tools", "stream", "temperature", - "max_tokens", "top_p", "top_k", "frequency_penalty", "presence_penalty", - "n", "seed", "modalities", "stop", "response_format", "tool_choice", - "extra_body", "extra_headers", "extra_query", "thinking", "service_tier", "timeout", + "prompt", + "system_prompt", + "messages", + "tools", + "stream", + "temperature", + "max_tokens", + "top_p", + "top_k", + "frequency_penalty", + "presence_penalty", + "n", + "seed", + "modalities", + "stop", + "response_format", + "tool_choice", + "extra_body", + "extra_headers", + "extra_query", + "thinking", + "service_tier", + "timeout", } reject_unsupported_kwargs( "Google SDK", @@ -748,11 +908,15 @@ def _build_http_options( raise ValueError( "Google SDK 不支持请求级 extra_query;请在 options.http_options 中配置。" ) - extra_body = validate_secret_free_payload( - extra_body, - "Google", - "extra_body", - ) if extra_body is not None else None + extra_body = ( + validate_secret_free_payload( + extra_body, + "Google", + "extra_body", + ) + if extra_body is not None + else None + ) values: dict[str, Any] = {} if extra_headers is not None: values["headers"] = dict(extra_headers) @@ -769,15 +933,37 @@ async def _resolve_async_result(result: Any) -> Any: def _generation_options(self) -> dict[str, Any]: supported = { - "temperature", "top_p", "top_k", "candidate_count", "max_output_tokens", - "stop_sequences", "presence_penalty", "frequency_penalty", "seed", - "response_mime_type", "response_schema", "response_json_schema", - "safety_settings", "cached_content", "response_modalities", "thinking_config", - "automatic_function_calling", "speech_config", "image_config", "labels", - "routing_config", "model_selection_config", "media_resolution", - "enable_enhanced_civic_answers", "service_tier", - "logprobs", "response_logprobs", "audio_timestamp", "audio_transcription_config", - "model_armor_config", "should_return_http_response", + "temperature", + "top_p", + "top_k", + "candidate_count", + "max_output_tokens", + "stop_sequences", + "presence_penalty", + "frequency_penalty", + "seed", + "response_mime_type", + "response_schema", + "response_json_schema", + "safety_settings", + "cached_content", + "response_modalities", + "thinking_config", + "automatic_function_calling", + "speech_config", + "image_config", + "labels", + "routing_config", + "model_selection_config", + "media_resolution", + "enable_enhanced_civic_answers", + "service_tier", + "logprobs", + "response_logprobs", + "audio_timestamp", + "audio_transcription_config", + "model_armor_config", + "should_return_http_response", } return {key: value for key, value in self._options.items() if key in supported} @@ -802,9 +988,7 @@ def _response_format_values(response_format: Any) -> dict[str, Any]: "response_mime_type": "application/json", "response_json_schema": dict(payload), } - raise ValueError( - "Google response_format 仅支持 json_object 或 json_schema。" - ) + raise ValueError("Google response_format 仅支持 json_object 或 json_schema。") def _generation_http_options( self, @@ -813,7 +997,13 @@ def _generation_http_options( extra_headers: dict[str, str] | None, extra_query: dict[str, Any] | None, timeout: float | None, - ) -> tuple[dict[str, Any] | None, dict[str, str] | None, dict[str, Any] | None, float | None, Any | None]: + ) -> tuple[ + dict[str, Any] | None, + dict[str, str] | None, + dict[str, Any] | None, + float | None, + Any | None, + ]: """合并模型级与调用级 HTTP 扩展,拒绝同名字段静默覆盖。""" options = getattr(self, "_options", {}) or {} configured_body = options.get("extra_body") @@ -834,9 +1024,7 @@ def _generation_http_options( ) overlap = sorted(set(configured_values).intersection(request_values)) if overlap: - raise ValueError( - "Google extra_body 与模型 options 重复: " + ", ".join(overlap) - ) + raise ValueError("Google extra_body 与模型 options 重复: " + ", ".join(overlap)) merged_body = {**configured_values, **request_values} or None effective_timeout = timeout if effective_timeout is None: @@ -902,9 +1090,18 @@ def _convert_tools(tools: list[dict[str, Any]] | None) -> list[Any] | None: converted_tools.append(tool) continue native_keys = { - "retrieval", "computer_use", "file_search", "google_search", "google_maps", - "code_execution", "enterprise_web_search", "google_search_retrieval", - "parallel_ai_search", "url_context", "mcp_servers", "exa_ai_search", + "retrieval", + "computer_use", + "file_search", + "google_search", + "google_maps", + "code_execution", + "enterprise_web_search", + "google_search_retrieval", + "parallel_ai_search", + "url_context", + "mcp_servers", + "exa_ai_search", } type_name = tool.get("type") type_aliases = { @@ -999,17 +1196,13 @@ def _build_generation_config( configured_max_tokens = self._options.get("max_tokens") configured_max_output_tokens = values.get("max_output_tokens") if configured_max_tokens is not None and configured_max_output_tokens is not None: - raise ValueError( - "Google options.max_tokens 与 options.max_output_tokens 重复。" - ) + raise ValueError("Google options.max_tokens 与 options.max_output_tokens 重复。") if configured_max_tokens is not None: values["max_output_tokens"] = configured_max_tokens configured_thinking = self._options.get("thinking") configured_thinking_config = values.get("thinking_config") if configured_thinking is not None and configured_thinking_config is not None: - raise ValueError( - "Google options.thinking 与 options.thinking_config 重复。" - ) + raise ValueError("Google options.thinking 与 options.thinking_config 重复。") if configured_thinking is not None: values["thinking_config"] = configured_thinking configured_response_format = self._options.get("response_format") @@ -1017,10 +1210,12 @@ def _build_generation_config( if response_format is not None: raise ValueError("Google response_format 与模型 options 重复。") response_format = configured_response_format - values.update({ - "system_instruction": system_prompt, - "tools": self._convert_tools(tools), - }) + values.update( + { + "system_instruction": system_prompt, + "tools": self._convert_tools(tools), + } + ) if temperature is not None or "temperature" not in values: values["temperature"] = 0.7 if temperature is None else temperature if max_tokens is not None: @@ -1068,7 +1263,9 @@ def _build_generation_config( ) if http_options is not None: values["http_options"] = http_options - return types.GenerateContentConfig(**{key: value for key, value in values.items() if value is not None}) + return types.GenerateContentConfig( + **{key: value for key, value in values.items() if value is not None} + ) @staticmethod def _decode_data_uri(value: str, context: str) -> tuple[str, bytes]: @@ -1238,19 +1435,17 @@ def function_response_value(value: Any) -> Any: if not name and isinstance(tool_call_id, str): name = tool_call_names.get(tool_call_id) if not name: - raise ValueError( - "Google 工具结果缺少可关联的 tool_call_id/name。" - ) + raise ValueError("Google 工具结果缺少可关联的 tool_call_id/name。") raw_content = message.get("content", message.get("response", {})) response_parts = raw_content if isinstance(raw_content, list) else [raw_content] parts = [] for response_part in response_parts: if isinstance(response_part, dict) and response_part.get("type") in { - "tool_result", "function_result", "function_response" + "tool_result", + "function_result", + "function_response", }: - response = response_part.get( - "response", response_part.get("content", {}) - ) + response = response_part.get("response", response_part.get("content", {})) part_name = response_part.get("name") or name part_id = response_part.get("tool_call_id") or tool_call_id else: @@ -1324,9 +1519,7 @@ def function_response_value(value: Any) -> Any: if isinstance(image_url, str) and image_url.startswith("data:"): mime_type, data = GoogleProvider._decode_data_uri(image_url, "图片") parts.append( - types.Part( - inline_data=types.Blob(mime_type=mime_type, data=data) - ) + types.Part(inline_data=types.Blob(mime_type=mime_type, data=data)) ) else: parts.append( @@ -1338,7 +1531,13 @@ def function_response_value(value: Any) -> Any: ) ) ) - elif part_type in {"audio_url", "input_audio", "video_url", "input_video", "file"}: + elif part_type in { + "audio_url", + "input_audio", + "video_url", + "input_video", + "file", + }: media_value = part.get( "audio_url", part.get("video_url", part.get("url", part.get("file_uri"))), @@ -1388,9 +1587,7 @@ def function_response_value(value: Any) -> Any: if not part_name and isinstance(part_id, str): part_name = tool_call_names.get(part_id) if not isinstance(part_name, str) or not part_name.strip(): - raise ValueError( - "Google 工具结果缺少可关联的 tool_call_id/name。" - ) + raise ValueError("Google 工具结果缺少可关联的 tool_call_id/name。") parts.append( types.Part( function_response=types.FunctionResponse( @@ -1419,9 +1616,13 @@ def function_response_value(value: Any) -> Any: ) from exc if not isinstance(arguments, Mapping): raise ValueError("Google function_call 的 arguments 必须是 JSON 对象。") - parts.append(types.Part(function_call=types.FunctionCall( - id=call_id, name=function_name, args=dict(arguments) - ))) + parts.append( + types.Part( + function_call=types.FunctionCall( + id=call_id, name=function_name, args=dict(arguments) + ) + ) + ) elif part_type == "function_response": part_id = part.get("tool_call_id") or part.get("id") part_name = part.get("name") or part.get("tool_name") @@ -1431,15 +1632,17 @@ def function_response_value(value: Any) -> Any: raise ValueError( "Google function_response 缺少可关联的 tool_call_id/name。" ) - parts.append(types.Part(function_response=types.FunctionResponse( - id=part_id, - name=part_name, - response=function_response_value(part.get("response", {})), - ))) - else: - raise ValueError( - f"Google 不支持的消息内容块类型: {part_type!r}。" + parts.append( + types.Part( + function_response=types.FunctionResponse( + id=part_id, + name=part_name, + response=function_response_value(part.get("response", {})), + ) + ) ) + else: + raise ValueError(f"Google 不支持的消息内容块类型: {part_type!r}。") else: parts = [types.Part(text=str(raw_content))] for index, call in enumerate(message.get("tool_calls", []) or []): @@ -1447,15 +1650,11 @@ def function_response_value(value: Any) -> Any: raise ValueError(f"Google assistant tool_calls[{index}] 必须是对象。") function = call.get("function", call) if not isinstance(function, Mapping): - raise ValueError( - f"Google assistant tool_calls[{index}].function 必须是对象。" - ) + raise ValueError(f"Google assistant tool_calls[{index}].function 必须是对象。") call_id = call.get("id") or call.get("call_id") function_name = function.get("name", "") if not isinstance(function_name, str) or not function_name.strip(): - raise ValueError( - f"Google assistant tool_calls[{index}] 缺少 function.name。" - ) + raise ValueError(f"Google assistant tool_calls[{index}] 缺少 function.name。") if call_id and function_name: tool_call_names[call_id] = function_name arguments = function.get("arguments", function.get("args", {})) @@ -1471,8 +1670,7 @@ def function_response_value(value: Any) -> Any: parts.append( types.Part( function_call=types.FunctionCall( - id=call_id, - name=function.get("name", ""), args=dict(arguments) + id=call_id, name=function.get("name", ""), args=dict(arguments) ) ) ) @@ -1612,21 +1810,19 @@ def _partial_arg_tokens(json_path: str) -> list[str | int]: dotted = re.match(r"^\.([A-Za-z_][A-Za-z0-9_]*)", rest) if dotted: tokens.append(dotted.group(1)) - rest = rest[dotted.end():] + rest = rest[dotted.end() :] continue indexed = re.match(r"^\[(\d+)\]", rest) if indexed: tokens.append(int(indexed.group(1))) - rest = rest[indexed.end():] + rest = rest[indexed.end() :] continue quoted = re.match(r"^\[['\"]([^'\"]+)['\"]\]", rest) if quoted: tokens.append(quoted.group(1)) - rest = rest[quoted.end():] + rest = rest[quoted.end() :] continue - raise ValueError( - f"Google FunctionCall.partial_args 不支持的 json_path: {json_path!r}" - ) + raise ValueError(f"Google FunctionCall.partial_args 不支持的 json_path: {json_path!r}") return tokens @classmethod @@ -1643,9 +1839,7 @@ def _partial_args_to_mapping(cls, partial_args: Any) -> dict[str, Any]: value = cls._partial_arg_value(partial) if not tokens: if not isinstance(value, Mapping): - raise ValueError( - "Google FunctionCall.partial_args 的根值必须是 JSON 对象。" - ) + raise ValueError("Google FunctionCall.partial_args 的根值必须是 JSON 对象。") result.update(dict(value)) continue current: Any = result @@ -1759,9 +1953,7 @@ def move_candidate(candidate_key: Any, target_key: Any) -> Any: if candidate_key == target_key: return target_key if target_key in accumulator: - raise RuntimeError( - "Google Gemini 工具调用分片的 id/index 指向多个调用。" - ) + raise RuntimeError("Google Gemini 工具调用分片的 id/index 指向多个调用。") accumulator[target_key] = accumulator.pop(candidate_key) return target_key @@ -1778,9 +1970,7 @@ def move_candidate(candidate_key: Any, target_key: Any) -> Any: if candidate.get("index") == index and name_matches(candidate) ] if len(candidates) > 1: - raise RuntimeError( - "Google Gemini 工具调用分片的 index 指向多个调用。" - ) + raise RuntimeError("Google Gemini 工具调用分片的 index 指向多个调用。") if candidates: key = candidates[0] else: @@ -1793,9 +1983,7 @@ def move_candidate(candidate_key: Any, target_key: Any) -> Any: if candidate_matches_id(candidate) ] if len(id_candidates) > 1: - raise RuntimeError( - "Google Gemini 工具调用分片的 id 指向多个调用。" - ) + raise RuntimeError("Google Gemini 工具调用分片的 id 指向多个调用。") if id_candidates: key = move_candidate(id_candidates[0], ("index", index)) else: @@ -1820,9 +2008,7 @@ def move_candidate(candidate_key: Any, target_key: Any) -> Any: if candidate_matches_id(candidate) ] if len(id_candidates) > 1: - raise RuntimeError( - "Google Gemini 工具调用分片的 id 指向多个调用。" - ) + raise RuntimeError("Google Gemini 工具调用分片的 id 指向多个调用。") if id_candidates: # Keep an existing explicit index as the accumulator key. This # handles streams where the first fragment has both id/index @@ -1884,19 +2070,13 @@ def move_candidate(candidate_key: Any, target_key: Any) -> Any: ) existing_id = merged.get("id") or merged.get("call_id") if call_id and existing_id and existing_id != call_id: - raise RuntimeError( - "Google Gemini 工具调用分片的 id 指向多个调用。" - ) + raise RuntimeError("Google Gemini 工具调用分片的 id 指向多个调用。") existing_index = merged.get("index") if index is not None and existing_index is not None and existing_index != index: - raise RuntimeError( - "Google Gemini 工具调用分片的 index 指向多个调用。" - ) + raise RuntimeError("Google Gemini 工具调用分片的 index 指向多个调用。") existing_name = merged.get("name") if name and existing_name and existing_name != name: - raise RuntimeError( - "Google Gemini 工具调用分片的 name 指向多个调用。" - ) + raise RuntimeError("Google Gemini 工具调用分片的 name 指向多个调用。") for name in ("id", "call_id", "type", "name", "index", "will_continue"): value = tool_call.get(name) if value is not None and value != "": @@ -2002,13 +2182,10 @@ def _stream_events_from_chunk(cls, chunk: Any) -> list[StreamEvent]: # immediate completion event from a duplicate delta. The # stream accumulator emits the completed call at finish, # where the same ambiguity is checked explicitly. - if ( - field(function_call, "will_continue") is False - and ( - field(function_call, "id") - or field(function_call, "call_id") - or cls._function_call_index(function_call) is not None - ) + if field(function_call, "will_continue") is False and ( + field(function_call, "id") + or field(function_call, "call_id") + or cls._function_call_index(function_call) is not None ): events.append( StreamEvent( @@ -2104,9 +2281,8 @@ def _stream_events_from_chunk(cls, chunk: Any) -> list[StreamEvent]: ) ) top_level_finish_reason = field(chunk, "finish_reason") - if ( - top_level_finish_reason is not None - and not any(event.type == "finish" for event in events) + if top_level_finish_reason is not None and not any( + event.type == "finish" for event in events ): events.append( StreamEvent( @@ -2118,7 +2294,9 @@ def _stream_events_from_chunk(cls, chunk: Any) -> list[StreamEvent]: ) usage = normalize_usage(field(chunk, "usage_metadata") or field(chunk, "usage")) if usage: - events.append(StreamEvent(type="usage", usage=usage, response_id=response_id, raw=chunk)) + events.append( + StreamEvent(type="usage", usage=usage, response_id=response_id, raw=chunk) + ) return events @staticmethod @@ -2162,19 +2340,23 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C ) client = self._get_client() vertexai = self._effective_vertex_mode(client) - response = retry_sync_call(lambda: client.models.generate_content( - model=self._model_name, - contents=self._contents( - request.messages, - request.prompt, - request.system_prompt, - vertex_mode=vertexai, - ), - config=config, - )) + response = retry_sync_call( + lambda: client.models.generate_content( + model=self._model_name, + contents=self._contents( + request.messages, + request.prompt, + request.system_prompt, + vertex_mode=vertexai, + ), + config=config, + ) + ) return self._extract_result(response) - async def acomplete(self, request: CompletionRequest | None = None, **kwargs: Any) -> CompletionResult: + async def acomplete( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> CompletionResult: """使用 google-genai 异步模型接口聚合完整结果。""" request = coerce_completion_request(request, kwargs, "Google acomplete") self._validate_request(request) @@ -2203,7 +2385,8 @@ async def acomplete(self, request: CompletionRequest | None = None, **kwargs: An ) client = self._get_client() vertexai = self._effective_vertex_mode(client) - response = await retry_async_call(lambda: client.aio.models.generate_content( + response = await retry_async_call( + lambda: client.aio.models.generate_content( model=self._model_name, contents=self._contents( request.messages, @@ -2212,7 +2395,8 @@ async def acomplete(self, request: CompletionRequest | None = None, **kwargs: An vertex_mode=vertexai, ), config=config, - )) + ) + ) return self._extract_result(response) def count_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) -> int: @@ -2255,11 +2439,13 @@ def count_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) None if vertexai is False else configured_system, vertex_mode=vertexai, ) - response = retry_sync_call(lambda: client.models.count_tokens( - model=self._model_name, - contents=contents, - config=types.CountTokensConfig(**config_values) if config_values else None, - )) + response = retry_sync_call( + lambda: client.models.count_tokens( + model=self._model_name, + contents=contents, + config=types.CountTokensConfig(**config_values) if config_values else None, + ) + ) value = field(response, "total_tokens") if value is None: raise RuntimeError("Google token count 响应缺少 total_tokens。") @@ -2290,18 +2476,24 @@ def compute_tokens(self, request: CompletionRequest | None = None, **kwargs: Any extra_query=request.extra_query, timeout=request.timeout, ) - config = types.ComputeTokensConfig(http_options=http_options) if http_options is not None else None + config = ( + types.ComputeTokensConfig(http_options=http_options) + if http_options is not None + else None + ) contents = self._contents( request.messages, request.prompt, None, vertex_mode=vertexai, ) - return retry_sync_call(lambda: client.models.compute_tokens( - model=self._model_name, - contents=contents, - config=config, - )) + return retry_sync_call( + lambda: client.models.compute_tokens( + model=self._model_name, + contents=contents, + config=config, + ) + ) def upload_file(self, file: Any, config: Any = None, **kwargs: Any) -> Any: return self._get_client().files.upload( @@ -2368,12 +2560,16 @@ def delete_file_search_document(self, name: str, config: Any = None, **kwargs: A ) async def async_upload_file(self, file: Any, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.files.upload( - file=file, - **self._resource_config_kwargs(types.UploadFileConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.files.upload( + file=file, + **self._resource_config_kwargs(types.UploadFileConfig, config, kwargs), + ) + ) - async def async_register_files(self, auth: Any, uris: list[str], config: Any = None, **kwargs: Any) -> Any: + async def async_register_files( + self, auth: Any, uris: list[str], config: Any = None, **kwargs: Any + ) -> Any: auth = self._validate_register_files_auth(auth) return await self._resolve_async_result( self._get_client().aio.files.register_files( @@ -2384,29 +2580,39 @@ async def async_register_files(self, auth: Any, uris: list[str], config: Any = N ) async def async_get_file(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.files.get( - name=name, - **self._resource_config_kwargs(types.GetFileConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.files.get( + name=name, + **self._resource_config_kwargs(types.GetFileConfig, config, kwargs), + ) + ) async def async_list_files(self, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.files.list( - **self._resource_config_kwargs(types.ListFilesConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.files.list( + **self._resource_config_kwargs(types.ListFilesConfig, config, kwargs), + ) + ) async def async_download_file(self, file: Any, config: Any = None, **kwargs: Any) -> bytes: - return await self._resolve_async_result(self._get_client().aio.files.download( - file=file, - **self._resource_config_kwargs(types.DownloadFileConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.files.download( + file=file, + **self._resource_config_kwargs(types.DownloadFileConfig, config, kwargs), + ) + ) async def async_delete_file(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.files.delete( - name=name, - **self._resource_config_kwargs(types.DeleteFileConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.files.delete( + name=name, + **self._resource_config_kwargs(types.DeleteFileConfig, config, kwargs), + ) + ) - async def async_list_file_search_documents(self, parent: str, config: Any = None, **kwargs: Any) -> Any: + async def async_list_file_search_documents( + self, parent: str, config: Any = None, **kwargs: Any + ) -> Any: return await self._resolve_async_result( self._get_client().aio.file_search_stores.documents.list( parent=parent, @@ -2414,7 +2620,9 @@ async def async_list_file_search_documents(self, parent: str, config: Any = None ) ) - async def async_get_file_search_document(self, name: str, config: Any = None, **kwargs: Any) -> Any: + async def async_get_file_search_document( + self, name: str, config: Any = None, **kwargs: Any + ) -> Any: return await self._resolve_async_result( self._get_client().aio.file_search_stores.documents.get( name=name, @@ -2422,7 +2630,9 @@ async def async_get_file_search_document(self, name: str, config: Any = None, ** ) ) - async def async_delete_file_search_document(self, name: str, config: Any = None, **kwargs: Any) -> Any: + async def async_delete_file_search_document( + self, name: str, config: Any = None, **kwargs: Any + ) -> Any: return await self._resolve_async_result( self._get_client().aio.file_search_stores.documents.delete( name=name, @@ -2430,7 +2640,9 @@ async def async_delete_file_search_document(self, name: str, config: Any = None, ) ) - async def async_count_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) -> int: + async def async_count_tokens( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> int: request = coerce_completion_request(request, kwargs, "Google async_count_tokens") self._validate_token_count_fields(request) client = self._get_client() @@ -2465,17 +2677,21 @@ async def async_count_tokens(self, request: CompletionRequest | None = None, **k None if vertexai is False else configured_system, vertex_mode=vertexai, ) - response = await retry_async_call(lambda: client.aio.models.count_tokens( + response = await retry_async_call( + lambda: client.aio.models.count_tokens( model=self._model_name, contents=contents, config=types.CountTokensConfig(**config_values) if config_values else None, - )) + ) + ) value = field(response, "total_tokens") if value is None: raise RuntimeError("Google token count 响应缺少 total_tokens。") return int(value) - async def async_compute_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) -> Any: + async def async_compute_tokens( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> Any: request = coerce_completion_request(request, kwargs, "Google async_compute_tokens") self._validate_compute_token_fields(request) client = self._get_client() @@ -2500,20 +2716,28 @@ async def async_compute_tokens(self, request: CompletionRequest | None = None, * extra_query=request.extra_query, timeout=request.timeout, ) - config = types.ComputeTokensConfig(http_options=http_options) if http_options is not None else None + config = ( + types.ComputeTokensConfig(http_options=http_options) + if http_options is not None + else None + ) contents = self._contents( request.messages, request.prompt, None, vertex_mode=vertexai, ) - return await retry_async_call(lambda: client.aio.models.compute_tokens( + return await retry_async_call( + lambda: client.aio.models.compute_tokens( model=self._model_name, contents=contents, config=config, - )) + ) + ) - def stream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> Generator[StreamEvent, None, None]: + def stream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> Generator[StreamEvent, None, None]: request = coerce_completion_request(request, kwargs, "Google stream_events") request = request.copy_with(stream=True) self._validate_request(request) @@ -2602,19 +2826,40 @@ def stream_events(self, request: CompletionRequest | None = None, **kwargs: Any) return result = self.complete(request) if result.text: - yield StreamEvent(type="text_delta", text=result.text, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="text_delta", text=result.text, response_id=result.response_id, raw=result.raw + ) if result.reasoning: yield StreamEvent(type="reasoning_delta", reasoning=result.reasoning, raw=result.raw) if result.refusal: - yield StreamEvent(type="refusal_delta", refusal=result.refusal, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="refusal_delta", + refusal=result.refusal, + response_id=result.response_id, + raw=result.raw, + ) for call in result.tool_calls: yield StreamEvent(type="tool_call_delta", tool_call=call, raw=result.raw) - yield StreamEvent(type="tool_call_completed", tool_call=call, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="tool_call_completed", + tool_call=call, + response_id=result.response_id, + raw=result.raw, + ) if result.usage: - yield StreamEvent(type="usage", usage=result.usage, response_id=result.response_id, raw=result.raw) - yield StreamEvent(type="finish", finish_reason=result.finish_reason, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="usage", usage=result.usage, response_id=result.response_id, raw=result.raw + ) + yield StreamEvent( + type="finish", + finish_reason=result.finish_reason, + response_id=result.response_id, + raw=result.raw, + ) - async def astream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> AsyncGenerator[StreamEvent, None]: + async def astream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> AsyncGenerator[StreamEvent, None]: request = coerce_completion_request(request, kwargs, "Google astream_events") request = request.copy_with(stream=True) self._validate_request(request) @@ -2704,17 +2949,36 @@ async def astream_events(self, request: CompletionRequest | None = None, **kwarg return result = await self.acomplete(request) if result.text: - yield StreamEvent(type="text_delta", text=result.text, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="text_delta", text=result.text, response_id=result.response_id, raw=result.raw + ) if result.reasoning: yield StreamEvent(type="reasoning_delta", reasoning=result.reasoning, raw=result.raw) if result.refusal: - yield StreamEvent(type="refusal_delta", refusal=result.refusal, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="refusal_delta", + refusal=result.refusal, + response_id=result.response_id, + raw=result.raw, + ) for call in result.tool_calls: yield StreamEvent(type="tool_call_delta", tool_call=call, raw=result.raw) - yield StreamEvent(type="tool_call_completed", tool_call=call, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="tool_call_completed", + tool_call=call, + response_id=result.response_id, + raw=result.raw, + ) if result.usage: - yield StreamEvent(type="usage", usage=result.usage, response_id=result.response_id, raw=result.raw) - yield StreamEvent(type="finish", finish_reason=result.finish_reason, response_id=result.response_id, raw=result.raw) + yield StreamEvent( + type="usage", usage=result.usage, response_id=result.response_id, raw=result.raw + ) + yield StreamEvent( + type="finish", + finish_reason=result.finish_reason, + response_id=result.response_id, + raw=result.raw, + ) def create_cache(self, config: Any = None, **kwargs: Any) -> Any: return self._get_client().caches.create( @@ -2734,49 +2998,66 @@ def list_caches(self, config: Any = None, **kwargs: Any) -> Any: def update_cache(self, name: str, config: Any = None, **kwargs: Any) -> Any: return self._get_client().caches.update( - name=name, **self._resource_config_kwargs(types.UpdateCachedContentConfig, config, kwargs) + name=name, + **self._resource_config_kwargs(types.UpdateCachedContentConfig, config, kwargs), ) def delete_cache(self, name: str, config: Any = None, **kwargs: Any) -> Any: return self._get_client().caches.delete( - name=name, **self._resource_config_kwargs(types.DeleteCachedContentConfig, config, kwargs) + name=name, + **self._resource_config_kwargs(types.DeleteCachedContentConfig, config, kwargs), ) async def async_create_cache(self, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.caches.create( - model=kwargs.pop("model", self._model_name), - **self._resource_config_kwargs(types.CreateCachedContentConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.caches.create( + model=kwargs.pop("model", self._model_name), + **self._resource_config_kwargs(types.CreateCachedContentConfig, config, kwargs), + ) + ) async def async_get_cache(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.caches.get( - name=name, **self._resource_config_kwargs(types.GetCachedContentConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.caches.get( + name=name, + **self._resource_config_kwargs(types.GetCachedContentConfig, config, kwargs), + ) + ) async def async_list_caches(self, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.caches.list( - **self._resource_config_kwargs(types.ListCachedContentsConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.caches.list( + **self._resource_config_kwargs(types.ListCachedContentsConfig, config, kwargs) + ) + ) async def async_update_cache(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.caches.update( - name=name, **self._resource_config_kwargs(types.UpdateCachedContentConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.caches.update( + name=name, + **self._resource_config_kwargs(types.UpdateCachedContentConfig, config, kwargs), + ) + ) async def async_delete_cache(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.caches.delete( - name=name, **self._resource_config_kwargs(types.DeleteCachedContentConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.caches.delete( + name=name, + **self._resource_config_kwargs(types.DeleteCachedContentConfig, config, kwargs), + ) + ) def create_batch(self, src: Any, config: Any = None, **kwargs: Any) -> Any: return self._get_client().batches.create( - model=kwargs.pop("model", self._model_name), src=src, + model=kwargs.pop("model", self._model_name), + src=src, **self._resource_config_kwargs(types.CreateBatchJobConfig, config, kwargs), ) def create_embedding_batch(self, src: Any, config: Any = None, **kwargs: Any) -> Any: return self._get_client().batches.create_embeddings( - model=kwargs.pop("model", self._model_name), src=src, + model=kwargs.pop("model", self._model_name), + src=src, **self._resource_config_kwargs(types.CreateEmbeddingsBatchJobConfig, config, kwargs), ) @@ -2801,36 +3082,56 @@ def delete_batch(self, name: str, config: Any = None, **kwargs: Any) -> Any: ) async def async_create_batch(self, src: Any, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.batches.create( - model=kwargs.pop("model", self._model_name), src=src, - **self._resource_config_kwargs(types.CreateBatchJobConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.batches.create( + model=kwargs.pop("model", self._model_name), + src=src, + **self._resource_config_kwargs(types.CreateBatchJobConfig, config, kwargs), + ) + ) - async def async_create_embedding_batch(self, src: Any, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.batches.create_embeddings( - model=kwargs.pop("model", self._model_name), src=src, - **self._resource_config_kwargs(types.CreateEmbeddingsBatchJobConfig, config, kwargs), - )) + async def async_create_embedding_batch( + self, src: Any, config: Any = None, **kwargs: Any + ) -> Any: + return await self._resolve_async_result( + self._get_client().aio.batches.create_embeddings( + model=kwargs.pop("model", self._model_name), + src=src, + **self._resource_config_kwargs( + types.CreateEmbeddingsBatchJobConfig, config, kwargs + ), + ) + ) async def async_get_batch(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.batches.get( - name=name, **self._resource_config_kwargs(types.GetBatchJobConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.batches.get( + name=name, **self._resource_config_kwargs(types.GetBatchJobConfig, config, kwargs) + ) + ) async def async_list_batches(self, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.batches.list( - **self._resource_config_kwargs(types.ListBatchJobsConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.batches.list( + **self._resource_config_kwargs(types.ListBatchJobsConfig, config, kwargs) + ) + ) async def async_cancel_batch(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.batches.cancel( - name=name, **self._resource_config_kwargs(types.CancelBatchJobConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.batches.cancel( + name=name, + **self._resource_config_kwargs(types.CancelBatchJobConfig, config, kwargs), + ) + ) async def async_delete_batch(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.batches.delete( - name=name, **self._resource_config_kwargs(types.DeleteBatchJobConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.batches.delete( + name=name, + **self._resource_config_kwargs(types.DeleteBatchJobConfig, config, kwargs), + ) + ) def list_models(self, config: Any = None, **kwargs: Any) -> Any: return self._get_client().models.list( @@ -2852,11 +3153,11 @@ def update_model(self, model: str, config: Any = None, **kwargs: Any) -> Any: model=model, **self._resource_config_kwargs(types.UpdateModelConfig, config, kwargs) ) - def tune(self, base_model: str, training_dataset: Any, config: Any = None, **kwargs: Any) -> Any: + def tune( + self, base_model: str, training_dataset: Any, config: Any = None, **kwargs: Any + ) -> Any: """创建 Google GenAI 调优任务。""" - tunings = self._require_resource( - self._get_client(), "tunings", "调优" - ) + tunings = self._require_resource(self._get_client(), "tunings", "调优") return tunings.tune( base_model=base_model, training_dataset=training_dataset, @@ -2912,57 +3213,77 @@ def validate_tuning_reward( **reward_kwargs, ) - def recontext_image(self, source: Any, model: str | None = None, config: Any = None, **kwargs: Any) -> Any: + def recontext_image( + self, source: Any, model: str | None = None, config: Any = None, **kwargs: Any + ) -> Any: return self._get_client().models.recontext_image( - model=model or self._model_name, source=source, + model=model or self._model_name, + source=source, **self._resource_config_kwargs(types.RecontextImageConfig, config, kwargs), ) async def async_list_models(self, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.list( - **self._resource_config_kwargs(types.ListModelsConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.models.list( + **self._resource_config_kwargs(types.ListModelsConfig, config, kwargs) + ) + ) async def async_get_model(self, model: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.get( - model=model, **self._resource_config_kwargs(types.GetModelConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.models.get( + model=model, **self._resource_config_kwargs(types.GetModelConfig, config, kwargs) + ) + ) async def async_delete_model(self, model: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.delete( - model=model, **self._resource_config_kwargs(types.DeleteModelConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.models.delete( + model=model, **self._resource_config_kwargs(types.DeleteModelConfig, config, kwargs) + ) + ) async def async_update_model(self, model: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.update( - model=model, **self._resource_config_kwargs(types.UpdateModelConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.models.update( + model=model, **self._resource_config_kwargs(types.UpdateModelConfig, config, kwargs) + ) + ) - async def async_tune(self, base_model: str, training_dataset: Any, config: Any = None, **kwargs: Any) -> Any: + async def async_tune( + self, base_model: str, training_dataset: Any, config: Any = None, **kwargs: Any + ) -> Any: tunings = self._require_resource(self._get_client().aio, "tunings", "异步调优") - return await self._resolve_async_result(tunings.tune( - base_model=base_model, - training_dataset=training_dataset, - **self._resource_config_kwargs(types.CreateTuningJobConfig, config, kwargs), - )) + return await self._resolve_async_result( + tunings.tune( + base_model=base_model, + training_dataset=training_dataset, + **self._resource_config_kwargs(types.CreateTuningJobConfig, config, kwargs), + ) + ) async def async_get_tuning(self, name: str, config: Any = None, **kwargs: Any) -> Any: tunings = self._require_resource(self._get_client().aio, "tunings", "异步调优") - return await self._resolve_async_result(tunings.get( - name=name, **self._resource_config_kwargs(types.GetTuningJobConfig, config, kwargs) - )) + return await self._resolve_async_result( + tunings.get( + name=name, **self._resource_config_kwargs(types.GetTuningJobConfig, config, kwargs) + ) + ) async def async_list_tunings(self, config: Any = None, **kwargs: Any) -> Any: tunings = self._require_resource(self._get_client().aio, "tunings", "异步调优") - return await self._resolve_async_result(tunings.list( - **self._resource_config_kwargs(types.ListTuningJobsConfig, config, kwargs) - )) + return await self._resolve_async_result( + tunings.list(**self._resource_config_kwargs(types.ListTuningJobsConfig, config, kwargs)) + ) async def async_cancel_tuning(self, name: str, config: Any = None, **kwargs: Any) -> Any: tunings = self._require_resource(self._get_client().aio, "tunings", "异步调优") - return await self._resolve_async_result(tunings.cancel( - name=name, **self._resource_config_kwargs(types.CancelTuningJobConfig, config, kwargs) - )) + return await self._resolve_async_result( + tunings.cancel( + name=name, + **self._resource_config_kwargs(types.CancelTuningJobConfig, config, kwargs), + ) + ) async def async_validate_tuning_reward( self, @@ -2988,70 +3309,129 @@ async def async_validate_tuning_reward( reward_kwargs.update( self._resource_config_kwargs(types.ValidateRewardConfig, config, kwargs) ) - return await self._resolve_async_result(tunings.validate_reward( - parent=parent, - sample_response=sample_response, - example=example, - **reward_kwargs, - )) + return await self._resolve_async_result( + tunings.validate_reward( + parent=parent, + sample_response=sample_response, + example=example, + **reward_kwargs, + ) + ) - async def async_recontext_image(self, source: Any, model: str | None = None, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.recontext_image( - model=model or self._model_name, source=source, - **self._resource_config_kwargs(types.RecontextImageConfig, config, kwargs), - )) + async def async_recontext_image( + self, source: Any, model: str | None = None, config: Any = None, **kwargs: Any + ) -> Any: + return await self._resolve_async_result( + self._get_client().aio.models.recontext_image( + model=model or self._model_name, + source=source, + **self._resource_config_kwargs(types.RecontextImageConfig, config, kwargs), + ) + ) - def generate_images(self, prompt: str, model: str | None = None, config: Any = None, **kwargs: Any) -> Any: + def generate_images( + self, prompt: str, model: str | None = None, config: Any = None, **kwargs: Any + ) -> Any: return self._get_client().models.generate_images( - model=model or self._model_name, prompt=prompt, + model=model or self._model_name, + prompt=prompt, **self._resource_config_kwargs(types.GenerateImagesConfig, config, kwargs), ) - def edit_image(self, prompt: str, reference_images: list[Any], model: str | None = None, config: Any = None, **kwargs: Any) -> Any: + def edit_image( + self, + prompt: str, + reference_images: list[Any], + model: str | None = None, + config: Any = None, + **kwargs: Any, + ) -> Any: return self._get_client().models.edit_image( - model=model or self._model_name, prompt=prompt, reference_images=reference_images, + model=model or self._model_name, + prompt=prompt, + reference_images=reference_images, **self._resource_config_kwargs(types.EditImageConfig, config, kwargs), ) - def upscale_image(self, image: Any, upscale_factor: str, model: str | None = None, config: Any = None, **kwargs: Any) -> Any: + def upscale_image( + self, + image: Any, + upscale_factor: str, + model: str | None = None, + config: Any = None, + **kwargs: Any, + ) -> Any: return self._get_client().models.upscale_image( - model=model or self._model_name, image=image, upscale_factor=upscale_factor, + model=model or self._model_name, + image=image, + upscale_factor=upscale_factor, **self._resource_config_kwargs(types.UpscaleImageConfig, config, kwargs), ) - def segment_image(self, source: Any, model: str | None = None, config: Any = None, **kwargs: Any) -> Any: + def segment_image( + self, source: Any, model: str | None = None, config: Any = None, **kwargs: Any + ) -> Any: return self._get_client().models.segment_image( - model=model or self._model_name, source=source, + model=model or self._model_name, + source=source, **self._resource_config_kwargs(types.SegmentImageConfig, config, kwargs), ) - async def async_generate_images(self, prompt: str, model: str | None = None, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.generate_images( - model=model or self._model_name, prompt=prompt, - **self._resource_config_kwargs(types.GenerateImagesConfig, config, kwargs), - )) + async def async_generate_images( + self, prompt: str, model: str | None = None, config: Any = None, **kwargs: Any + ) -> Any: + return await self._resolve_async_result( + self._get_client().aio.models.generate_images( + model=model or self._model_name, + prompt=prompt, + **self._resource_config_kwargs(types.GenerateImagesConfig, config, kwargs), + ) + ) - async def async_edit_image(self, prompt: str, reference_images: list[Any], model: str | None = None, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.edit_image( - model=model or self._model_name, - prompt=prompt, - reference_images=reference_images, - **self._resource_config_kwargs(types.EditImageConfig, config, kwargs), - )) + async def async_edit_image( + self, + prompt: str, + reference_images: list[Any], + model: str | None = None, + config: Any = None, + **kwargs: Any, + ) -> Any: + return await self._resolve_async_result( + self._get_client().aio.models.edit_image( + model=model or self._model_name, + prompt=prompt, + reference_images=reference_images, + **self._resource_config_kwargs(types.EditImageConfig, config, kwargs), + ) + ) - async def async_upscale_image(self, image: Any, upscale_factor: str, model: str | None = None, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.upscale_image( - model=model or self._model_name, - image=image, - upscale_factor=upscale_factor, - **self._resource_config_kwargs(types.UpscaleImageConfig, config, kwargs), - )) + async def async_upscale_image( + self, + image: Any, + upscale_factor: str, + model: str | None = None, + config: Any = None, + **kwargs: Any, + ) -> Any: + return await self._resolve_async_result( + self._get_client().aio.models.upscale_image( + model=model or self._model_name, + image=image, + upscale_factor=upscale_factor, + **self._resource_config_kwargs(types.UpscaleImageConfig, config, kwargs), + ) + ) - async def async_segment_image(self, source: Any, model: str | None = None, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.segment_image( - model=model or self._model_name, source=source, - **self._resource_config_kwargs(types.SegmentImageConfig, config, kwargs), - )) + async def async_segment_image( + self, source: Any, model: str | None = None, config: Any = None, **kwargs: Any + ) -> Any: + return await self._resolve_async_result( + self._get_client().aio.models.segment_image( + model=model or self._model_name, + source=source, + **self._resource_config_kwargs(types.SegmentImageConfig, config, kwargs), + ) + ) def generate_videos( self, @@ -3064,7 +3444,11 @@ def generate_videos( **kwargs: Any, ) -> Any: return self._get_client().models.generate_videos( - model=model or self._model_name, prompt=prompt, image=image, video=video, source=source, + model=model or self._model_name, + prompt=prompt, + image=image, + video=video, + source=source, **self._resource_config_kwargs(types.GenerateVideosConfig, config, kwargs), ) @@ -3083,15 +3467,23 @@ async def async_generate_videos( config: Any = None, **kwargs: Any, ) -> Any: - return await self._resolve_async_result(self._get_client().aio.models.generate_videos( - model=model or self._model_name, prompt=prompt, image=image, video=video, source=source, - **self._resource_config_kwargs(types.GenerateVideosConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.models.generate_videos( + model=model or self._model_name, + prompt=prompt, + image=image, + video=video, + source=source, + **self._resource_config_kwargs(types.GenerateVideosConfig, config, kwargs), + ) + ) async def async_get_operation(self, operation: Any, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.operations.get( - operation, **self._resource_config_kwargs(types.GetOperationConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.operations.get( + operation, **self._resource_config_kwargs(types.GetOperationConfig, config, kwargs) + ) + ) def create_chat( self, @@ -3113,11 +3505,13 @@ async def async_create_chat( ) -> Any: # google-genai returns an AsyncChat object synchronously; subsequent # send_message calls on that object are awaitable. - return await self._resolve_async_result(self._get_client().aio.chats.create( - model=kwargs.pop("model", self._model_name), - history=history, - **self._resource_config_kwargs(types.GenerateContentConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.chats.create( + model=kwargs.pop("model", self._model_name), + history=history, + **self._resource_config_kwargs(types.GenerateContentConfig, config, kwargs), + ) + ) def create_file_search_store(self, config: Any = None, **kwargs: Any) -> Any: return self._get_client().file_search_stores.create( @@ -3256,15 +3650,11 @@ def connect_live(self, *, model: str | None = None, config: Any = None) -> Any: try: aio = client.aio except (AttributeError, NotImplementedError) as exc: - raise NotImplementedError( - "当前 google-genai SDK 未提供异步 Live 资源。" - ) from exc + raise NotImplementedError("当前 google-genai SDK 未提供异步 Live 资源。") from exc live = self._require_resource(aio, "live", "Live") connect = self._require_resource(live, "connect", "Live 连接") if not callable(connect): - raise NotImplementedError( - "当前 google-genai SDK 未提供可调用的 Live 连接资源。" - ) + raise NotImplementedError("当前 google-genai SDK 未提供可调用的 Live 连接资源。") return connect(model=model, config=config) def async_connect_live(self, *, model: str | None = None, config: Any = None) -> Any: @@ -3274,7 +3664,8 @@ def async_connect_live(self, *, model: str | None = None, config: Any = None) -> def get_file_search_store(self, name: str, config: Any = None, **kwargs: Any) -> Any: return self._get_client().file_search_stores.get( - name=name, **self._resource_config_kwargs(types.GetFileSearchStoreConfig, config, kwargs) + name=name, + **self._resource_config_kwargs(types.GetFileSearchStoreConfig, config, kwargs), ) def list_file_search_stores(self, config: Any = None, **kwargs: Any) -> Any: @@ -3284,7 +3675,8 @@ def list_file_search_stores(self, config: Any = None, **kwargs: Any) -> Any: def delete_file_search_store(self, name: str, config: Any = None, **kwargs: Any) -> Any: return self._get_client().file_search_stores.delete( - name=name, **self._resource_config_kwargs(types.DeleteFileSearchStoreConfig, config, kwargs) + name=name, + **self._resource_config_kwargs(types.DeleteFileSearchStoreConfig, config, kwargs), ) def import_file_to_file_search_store( @@ -3300,44 +3692,60 @@ def import_file_to_file_search_store( **self._resource_config_kwargs(types.ImportFileConfig, config, kwargs), ) - def upload_to_file_search_store(self, file_search_store_name: str, file: Any, config: Any = None, **kwargs: Any) -> Any: + def upload_to_file_search_store( + self, file_search_store_name: str, file: Any, config: Any = None, **kwargs: Any + ) -> Any: return self._get_client().file_search_stores.upload_to_file_search_store( - file_search_store_name=file_search_store_name, file=file, + file_search_store_name=file_search_store_name, + file=file, **self._resource_config_kwargs(types.UploadToFileSearchStoreConfig, config, kwargs), ) def download_file_search_media(self, media_id: str, config: Any = None, **kwargs: Any) -> bytes: return self._get_client().file_search_stores.download_media( - media_id=media_id, **self._resource_config_kwargs(types.DownloadMediaConfig, config, kwargs) + media_id=media_id, + **self._resource_config_kwargs(types.DownloadMediaConfig, config, kwargs), ) async def async_create_file_search_store(self, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.file_search_stores.create( - **self._resource_config_kwargs(types.CreateFileSearchStoreConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.file_search_stores.create( + **self._resource_config_kwargs(types.CreateFileSearchStoreConfig, config, kwargs) + ) + ) async def async_create_auth_token(self, config: Any = None, **kwargs: Any) -> Any: """异步创建 Google GenAI Auth Token。""" self._require_provider_resource("async_create_auth_token") - return await self._resolve_async_result(self._get_client().aio.auth_tokens.create( - **self._resource_config_kwargs(types.CreateAuthTokenConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.auth_tokens.create( + **self._resource_config_kwargs(types.CreateAuthTokenConfig, config, kwargs) + ) + ) async def async_create_interaction(self, **kwargs: Any) -> Any: self._require_provider_resource("async_create_interaction") - return await self._resolve_async_result(self._get_client().aio.interactions.create(**kwargs)) + return await self._resolve_async_result( + self._get_client().aio.interactions.create(**kwargs) + ) async def async_get_interaction(self, interaction_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_get_interaction") - return await self._resolve_async_result(self._get_client().aio.interactions.get(id=interaction_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.interactions.get(id=interaction_id, **kwargs) + ) async def async_cancel_interaction(self, interaction_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_cancel_interaction") - return await self._resolve_async_result(self._get_client().aio.interactions.cancel(id=interaction_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.interactions.cancel(id=interaction_id, **kwargs) + ) async def async_delete_interaction(self, interaction_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_delete_interaction") - return await self._resolve_async_result(self._get_client().aio.interactions.delete(id=interaction_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.interactions.delete(id=interaction_id, **kwargs) + ) async def async_create_agent(self, **kwargs: Any) -> Any: self._require_provider_resource("async_create_agent") @@ -3345,7 +3753,9 @@ async def async_create_agent(self, **kwargs: Any) -> Any: async def async_get_agent(self, agent_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_get_agent") - return await self._resolve_async_result(self._get_client().aio.agents.get(agent_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.agents.get(agent_id, **kwargs) + ) async def async_list_agents(self, **kwargs: Any) -> Any: self._require_provider_resource("async_list_agents") @@ -3353,7 +3763,9 @@ async def async_list_agents(self, **kwargs: Any) -> Any: async def async_delete_agent(self, agent_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_delete_agent") - return await self._resolve_async_result(self._get_client().aio.agents.delete(agent_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.agents.delete(agent_id, **kwargs) + ) async def async_create_webhook(self, **kwargs: Any) -> Any: self._require_provider_resource("async_create_webhook") @@ -3361,7 +3773,9 @@ async def async_create_webhook(self, **kwargs: Any) -> Any: async def async_get_webhook(self, webhook_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_get_webhook") - return await self._resolve_async_result(self._get_client().aio.webhooks.get(webhook_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.webhooks.get(webhook_id, **kwargs) + ) async def async_list_webhooks(self, **kwargs: Any) -> Any: self._require_provider_resource("async_list_webhooks") @@ -3369,15 +3783,21 @@ async def async_list_webhooks(self, **kwargs: Any) -> Any: async def async_update_webhook(self, webhook_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_update_webhook") - return await self._resolve_async_result(self._get_client().aio.webhooks.update(webhook_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.webhooks.update(webhook_id, **kwargs) + ) async def async_delete_webhook(self, webhook_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_delete_webhook") - return await self._resolve_async_result(self._get_client().aio.webhooks.delete(webhook_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.webhooks.delete(webhook_id, **kwargs) + ) async def async_ping_webhook(self, webhook_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_ping_webhook") - return await self._resolve_async_result(self._get_client().aio.webhooks.ping(webhook_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.webhooks.ping(webhook_id, **kwargs) + ) async def async_rotate_webhook_signing_secret(self, webhook_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_rotate_webhook_signing_secret") @@ -3424,7 +3844,9 @@ async def async_create_trigger(self, **kwargs: Any) -> Any: async def async_get_trigger(self, trigger_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_get_trigger") - return await self._resolve_async_result(self._get_client().aio.triggers.get(trigger_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.triggers.get(trigger_id, **kwargs) + ) async def async_list_triggers(self, **kwargs: Any) -> Any: self._require_provider_resource("async_list_triggers") @@ -3432,15 +3854,21 @@ async def async_list_triggers(self, **kwargs: Any) -> Any: async def async_update_trigger(self, trigger_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_update_trigger") - return await self._resolve_async_result(self._get_client().aio.triggers.update(trigger_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.triggers.update(trigger_id, **kwargs) + ) async def async_delete_trigger(self, trigger_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_delete_trigger") - return await self._resolve_async_result(self._get_client().aio.triggers.delete(trigger_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.triggers.delete(trigger_id, **kwargs) + ) async def async_run_trigger(self, trigger_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_run_trigger") - return await self._resolve_async_result(self._get_client().aio.triggers.run(trigger_id, **kwargs)) + return await self._resolve_async_result( + self._get_client().aio.triggers.run(trigger_id, **kwargs) + ) async def async_list_trigger_executions(self, trigger_id: str, **kwargs: Any) -> Any: self._require_provider_resource("async_list_trigger_executions") @@ -3448,20 +3876,32 @@ async def async_list_trigger_executions(self, trigger_id: str, **kwargs: Any) -> self._get_client().aio.triggers.list_executions(trigger_id, **kwargs) ) - async def async_get_file_search_store(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.file_search_stores.get( - name=name, **self._resource_config_kwargs(types.GetFileSearchStoreConfig, config, kwargs) - )) + async def async_get_file_search_store( + self, name: str, config: Any = None, **kwargs: Any + ) -> Any: + return await self._resolve_async_result( + self._get_client().aio.file_search_stores.get( + name=name, + **self._resource_config_kwargs(types.GetFileSearchStoreConfig, config, kwargs), + ) + ) async def async_list_file_search_stores(self, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.file_search_stores.list( - **self._resource_config_kwargs(types.ListFileSearchStoresConfig, config, kwargs) - )) + return await self._resolve_async_result( + self._get_client().aio.file_search_stores.list( + **self._resource_config_kwargs(types.ListFileSearchStoresConfig, config, kwargs) + ) + ) - async def async_delete_file_search_store(self, name: str, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.file_search_stores.delete( - name=name, **self._resource_config_kwargs(types.DeleteFileSearchStoreConfig, config, kwargs) - )) + async def async_delete_file_search_store( + self, name: str, config: Any = None, **kwargs: Any + ) -> Any: + return await self._resolve_async_result( + self._get_client().aio.file_search_stores.delete( + name=name, + **self._resource_config_kwargs(types.DeleteFileSearchStoreConfig, config, kwargs), + ) + ) async def async_import_file_to_file_search_store( self, @@ -3470,23 +3910,34 @@ async def async_import_file_to_file_search_store( config: Any = None, **kwargs: Any, ) -> Any: - return await self._resolve_async_result(self._get_client().aio.file_search_stores.import_file( - file_search_store_name=file_search_store_name, - file_name=file_name, - **self._resource_config_kwargs(types.ImportFileConfig, config, kwargs), - )) + return await self._resolve_async_result( + self._get_client().aio.file_search_stores.import_file( + file_search_store_name=file_search_store_name, + file_name=file_name, + **self._resource_config_kwargs(types.ImportFileConfig, config, kwargs), + ) + ) - async def async_upload_to_file_search_store(self, file_search_store_name: str, file: Any, config: Any = None, **kwargs: Any) -> Any: - return await self._resolve_async_result(self._get_client().aio.file_search_stores.upload_to_file_search_store( - file_search_store_name=file_search_store_name, file=file, - **self._resource_config_kwargs(types.UploadToFileSearchStoreConfig, config, kwargs), - )) + async def async_upload_to_file_search_store( + self, file_search_store_name: str, file: Any, config: Any = None, **kwargs: Any + ) -> Any: + return await self._resolve_async_result( + self._get_client().aio.file_search_stores.upload_to_file_search_store( + file_search_store_name=file_search_store_name, + file=file, + **self._resource_config_kwargs(types.UploadToFileSearchStoreConfig, config, kwargs), + ) + ) - async def async_download_file_search_media(self, media_id: str, config: Any = None, **kwargs: Any) -> bytes: - return await self._resolve_async_result(self._get_client().aio.file_search_stores.download_media( - media_id=media_id, - **self._resource_config_kwargs(types.DownloadMediaConfig, config, kwargs), - )) + async def async_download_file_search_media( + self, media_id: str, config: Any = None, **kwargs: Any + ) -> bytes: + return await self._resolve_async_result( + self._get_client().aio.file_search_stores.download_media( + media_id=media_id, + **self._resource_config_kwargs(types.DownloadMediaConfig, config, kwargs), + ) + ) def invoke( self, @@ -3520,15 +3971,28 @@ def invoke( client = self._get_client() start_time = time.perf_counter() effective_system_prompt = extract_system_prompt(messages) or system_prompt - config = self._build_generation_config(effective_system_prompt, tools, temperature, max_tokens=max_tokens, - top_p=top_p, top_k=top_k, candidate_count=n, - frequency_penalty=frequency_penalty, - presence_penalty=presence_penalty, seed=seed, - modalities=modalities, stop=stop, response_format=response_format, - tool_choice=tool_choice, extra_body=extra_body, - extra_headers=extra_headers, extra_query=extra_query, - timeout=timeout, - thinking=thinking, service_tier=service_tier) + config = self._build_generation_config( + effective_system_prompt, + tools, + temperature, + max_tokens=max_tokens, + top_p=top_p, + top_k=top_k, + candidate_count=n, + frequency_penalty=frequency_penalty, + presence_penalty=presence_penalty, + seed=seed, + modalities=modalities, + stop=stop, + response_format=response_format, + tool_choice=tool_choice, + extra_body=extra_body, + extra_headers=extra_headers, + extra_query=extra_query, + timeout=timeout, + thinking=thinking, + service_tier=service_tier, + ) contents = self._contents( messages, prompt, @@ -3569,7 +4033,7 @@ def invoke( text = self._response_text(response) if text: yield text - + duration = time.perf_counter() - start_time logger.info(f"Google LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s") except Exception as e: @@ -3613,15 +4077,28 @@ async def ainvoke( client = self._get_client() start_time = time.perf_counter() effective_system_prompt = extract_system_prompt(messages) or system_prompt - config = self._build_generation_config(effective_system_prompt, tools, temperature, max_tokens=max_tokens, - top_p=top_p, top_k=top_k, candidate_count=n, - frequency_penalty=frequency_penalty, - presence_penalty=presence_penalty, seed=seed, - modalities=modalities, stop=stop, response_format=response_format, - tool_choice=tool_choice, extra_body=extra_body, - extra_headers=extra_headers, extra_query=extra_query, - timeout=timeout, - thinking=thinking, service_tier=service_tier) + config = self._build_generation_config( + effective_system_prompt, + tools, + temperature, + max_tokens=max_tokens, + top_p=top_p, + top_k=top_k, + candidate_count=n, + frequency_penalty=frequency_penalty, + presence_penalty=presence_penalty, + seed=seed, + modalities=modalities, + stop=stop, + response_format=response_format, + tool_choice=tool_choice, + extra_body=extra_body, + extra_headers=extra_headers, + extra_query=extra_query, + timeout=timeout, + thinking=thinking, + service_tier=service_tier, + ) contents = self._contents( messages, prompt, @@ -3651,16 +4128,18 @@ async def ainvoke( if not terminal_seen: raise RuntimeError("Google Gemini 异步流在终止事件之前结束。") else: - response = await retry_async_call(lambda: client.aio.models.generate_content( + response = await retry_async_call( + lambda: client.aio.models.generate_content( model=self._model_name, contents=contents, config=config - )) + ) + ) refusal = self._extract_refusal(response) if refusal: raise RuntimeError(f"Google 请求被拒绝: {refusal}") text = self._response_text(response) if text: yield text - + duration = time.perf_counter() - start_time logger.info(f"Google LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s") except Exception as e: @@ -3676,13 +4155,13 @@ async def ainvoke( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception(is_retryable_error), - reraise=True + reraise=True, ) def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: """同步向量化文档。""" logger.info(f"调用 Google Embedding ({self._model_name}),数量: {len(texts)}") start_time = time.perf_counter() - + try: config_values = self._embedding_options() overlap = sorted({"model", "contents", "config"}.intersection(kwargs)) @@ -3721,9 +4200,7 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: ) values = list(field(response, "embeddings", []) or []) if len(values) != len(texts): - raise RuntimeError( - "Google Embedding 返回数量与输入文本数量不一致。" - ) + raise RuntimeError("Google Embedding 返回数量与输入文本数量不一致。") embeddings = [normalize_embedding_vector(field(item, "values")) for item in values] duration = time.perf_counter() - start_time logger.info(f"Google Embedding ({self._model_name}) 完成,耗时: {duration:.2f}s") @@ -3741,13 +4218,13 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception(is_retryable_error), - reraise=True + reraise=True, ) async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: """异步向量化文档。""" logger.info(f"异步调用 Google Embedding ({self._model_name}),数量: {len(texts)}") start_time = time.perf_counter() - + try: config_values = self._embedding_options() overlap = sorted({"model", "contents", "config"}.intersection(kwargs)) @@ -3790,9 +4267,7 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f ) values = list(field(response, "embeddings", []) or []) if len(values) != len(texts): - raise RuntimeError( - "Google Embedding 返回数量与输入文本数量不一致。" - ) + raise RuntimeError("Google Embedding 返回数量与输入文本数量不一致。") embeddings = [normalize_embedding_vector(field(item, "values")) for item in values] duration = time.perf_counter() - start_time logger.info(f"Google Embedding ({self._model_name}) 异步完成,耗时: {duration:.2f}s") diff --git a/src/providers/grok.py b/src/providers/grok.py index f46b6a0..89b507f 100644 --- a/src/providers/grok.py +++ b/src/providers/grok.py @@ -6,5 +6,10 @@ class GrokProvider(OpenAICompatibleProvider): """Grok 模型提供商。""" - def __init__(self, model_name: str, protocol: str = "chat_completions", options: dict[str, Any] | None = None): + def __init__( + self, + model_name: str, + protocol: str = "chat_completions", + options: dict[str, Any] | None = None, + ): super().__init__(model_name=model_name, provider="grok", protocol=protocol, options=options) diff --git a/src/providers/jina.py b/src/providers/jina.py index 30a41ac..9511565 100644 --- a/src/providers/jina.py +++ b/src/providers/jina.py @@ -16,6 +16,7 @@ logger = get_module_logger(__name__) + class JinaProvider(RerankModel): """ Jina Rerank模型提供商。 @@ -23,7 +24,15 @@ class JinaProvider(RerankModel): """ _OPTION_KEYS = frozenset( - {"return_documents", "max_chunks_per_doc", "late_chunking", "truncate", "embedding_type", "extra_body", "timeout"} + { + "return_documents", + "max_chunks_per_doc", + "late_chunking", + "truncate", + "embedding_type", + "extra_body", + "timeout", + } ) def __init__(self, model_name: str, options: dict[str, Any] | None = None): @@ -67,14 +76,12 @@ def _prepare_payload(self, query: str, documents: list[str], top_n: int) -> dict option_overlap = sorted(set(options).intersection(extra_body)) if option_overlap: raise ValueError( - "Jina Rerank options 与 options.extra_body 重复: " - + ", ".join(option_overlap) + "Jina Rerank options 与 options.extra_body 重复: " + ", ".join(option_overlap) ) body_overlap = sorted(set(payload).intersection(extra_body)) if body_overlap: raise ValueError( - "Jina Rerank options.extra_body 不允许覆盖请求字段: " - + ", ".join(body_overlap) + "Jina Rerank options.extra_body 不允许覆盖请求字段: " + ", ".join(body_overlap) ) options.update(dict(extra_body)) payload.update(options) @@ -102,7 +109,9 @@ def _validate_inputs(query: str, documents: list[str], top_n: int) -> None: if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n < 1: raise ValueError("Jina Rerank top_n 必须是大于等于 1 的整数。") - def _parse_response(self, results: list[dict], documents: list[str]) -> tuple[list[int], list[float]]: + def _parse_response( + self, results: list[dict], documents: list[str] + ) -> tuple[list[int], list[float]]: if not isinstance(results, list): raise RuntimeError("Jina Rerank 响应缺少有效的 results 列表。") # 创建内容到索引的映射 @@ -126,17 +135,13 @@ def _parse_response(self, results: list[dict], documents: list[str]) -> tuple[li raise RuntimeError("Jina Rerank 响应包含重复 index。") index = direct_index document = res.get("document") - document_text = ( - document.get("text") if isinstance(document, Mapping) else document - ) + document_text = document.get("text") if isinstance(document, Mapping) else document if document_text is not None and document_text != documents[index]: raise RuntimeError("Jina Rerank 响应 index 与 document 不匹配。") positions.get(documents[index], []).remove(index) else: document = res.get("document") - doc_content = ( - document.get("text") if isinstance(document, Mapping) else document - ) + doc_content = document.get("text") if isinstance(document, Mapping) else document if not isinstance(doc_content, str) or not positions.get(doc_content): raise RuntimeError("Jina Rerank 响应无法映射到输入文档。") index = positions[doc_content].pop(0) @@ -154,20 +159,21 @@ def _parse_response(self, results: list[dict], documents: list[str]) -> tuple[li stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception(is_retryable_error), - reraise=True + reraise=True, ) def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int], list[float]]: """同步 Rerank (CSE Sensor)。""" logger.info(f"调用 Jina Rerank ({self._model_name}),文档数: {len(documents)}") import httpx + start_time = time.perf_counter() - + try: with httpx.Client(timeout=self._request_timeout()) as client: response = client.post( self._base_url, headers=self._get_headers(), - json=self._prepare_payload(query, documents, top_n) + json=self._prepare_payload(query, documents, top_n), ) response.raise_for_status() payload = response.json() @@ -176,7 +182,7 @@ def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int results = payload.get("results") if not isinstance(results, list): raise RuntimeError("Jina Rerank 响应缺少有效的 results 列表。") - + indices, scores = self._parse_response(results, documents) duration = time.perf_counter() - start_time logger.info(f"Jina Rerank ({self._model_name}) 完成,耗时: {duration:.2f}s") @@ -194,20 +200,23 @@ def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception(is_retryable_error), - reraise=True + reraise=True, ) - async def arerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int], list[float]]: + async def arerank( + self, query: str, documents: list[str], top_n: int + ) -> tuple[list[int], list[float]]: """异步 Rerank (CSE Sensor)。""" logger.info(f"异步调用 Jina Rerank ({self._model_name}),文档数: {len(documents)}") import httpx + start_time = time.perf_counter() - + try: async with httpx.AsyncClient(timeout=self._request_timeout()) as aclient: response = await aclient.post( self._base_url, headers=self._get_headers(), - json=self._prepare_payload(query, documents, top_n) + json=self._prepare_payload(query, documents, top_n), ) response.raise_for_status() payload = response.json() @@ -216,7 +225,7 @@ async def arerank(self, query: str, documents: list[str], top_n: int) -> tuple[l results = payload.get("results") if not isinstance(results, list): raise RuntimeError("Jina Rerank 响应缺少有效的 results 列表。") - + indices, scores = self._parse_response(results, documents) duration = time.perf_counter() - start_time logger.info(f"Jina Rerank ({self._model_name}) 异步完成,耗时: {duration:.2f}s") diff --git a/src/providers/lm_studio.py b/src/providers/lm_studio.py index ba99b45..685eb2d 100644 --- a/src/providers/lm_studio.py +++ b/src/providers/lm_studio.py @@ -9,5 +9,12 @@ class LMStudioProvider(OpenAICompatibleProvider): 通过继承OpenAICompatibleProvider来复用与OpenAI API兼容的逻辑。 """ - def __init__(self, model_name: str, protocol: str = "chat_completions", options: dict[str, Any] | None = None): - super().__init__(model_name=model_name, provider="lm-studio", protocol=protocol, options=options) + def __init__( + self, + model_name: str, + protocol: str = "chat_completions", + options: dict[str, Any] | None = None, + ): + super().__init__( + model_name=model_name, provider="lm-studio", protocol=protocol, options=options + ) diff --git a/src/providers/local_hash.py b/src/providers/local_hash.py index 01718ed..3721bbb 100644 --- a/src/providers/local_hash.py +++ b/src/providers/local_hash.py @@ -53,9 +53,7 @@ def _embed_text(self, text: str) -> list[float]: def _validate_kwargs(kwargs: dict) -> None: if kwargs: unsupported = ", ".join(sorted(str(key) for key in kwargs)) - raise ValueError( - "LocalHash Embedding 不支持请求参数: " + unsupported - ) + raise ValueError("LocalHash Embedding 不支持请求参数: " + unsupported) def embed_documents(self, texts: list[str], **kwargs) -> list[list[float]]: self._validate_kwargs(kwargs) diff --git a/src/providers/ollama.py b/src/providers/ollama.py index da85710..34a8abe 100644 --- a/src/providers/ollama.py +++ b/src/providers/ollama.py @@ -9,5 +9,12 @@ class OllamaProvider(OpenAICompatibleProvider): 通过继承OpenAICompatibleProvider来复用与OpenAI API兼容的逻辑。 """ - def __init__(self, model_name: str, protocol: str = "chat_completions", options: dict[str, Any] | None = None): - super().__init__(model_name=model_name, provider="ollama", protocol=protocol, options=options) + def __init__( + self, + model_name: str, + protocol: str = "chat_completions", + options: dict[str, Any] | None = None, + ): + super().__init__( + model_name=model_name, provider="ollama", protocol=protocol, options=options + ) diff --git a/src/providers/openai.py b/src/providers/openai.py index b9c137d..4d1dbc6 100644 --- a/src/providers/openai.py +++ b/src/providers/openai.py @@ -13,17 +13,42 @@ class OpenAIProvider(OpenAICompatibleProvider): capabilities = OpenAICompatibleProvider.capabilities | frozenset( { - "files", "batches", "vector_stores", "responses", "moderation", - "audio", "images", "videos", "uploads", "parse", "conversations", - "containers", "fine_tuning", "evals", "skills", "realtime", - "webhooks", "admin", "content_provenance_checks", + "files", + "batches", + "vector_stores", + "responses", + "moderation", + "audio", + "images", + "videos", + "uploads", + "parse", + "conversations", + "containers", + "fine_tuning", + "evals", + "skills", + "realtime", + "webhooks", + "admin", + "content_provenance_checks", } ) _resource_capabilities = frozenset( { - "files", "batches", "vector_stores", "models", "moderation", - "images", "audio", "videos", "uploads", "conversations", - "containers", "fine_tuning", "evals", + "files", + "batches", + "vector_stores", + "models", + "moderation", + "images", + "audio", + "videos", + "uploads", + "conversations", + "containers", + "fine_tuning", + "evals", } ) @@ -35,10 +60,21 @@ def resources(self) -> OpenAIResources: def async_resources(self) -> AsyncOpenAIResources: return AsyncOpenAIResources(self) - def __init__(self, model_name: str, protocol: str = "chat_completions", options: dict[str, Any] | None = None): + def __init__( + self, + model_name: str, + protocol: str = "chat_completions", + options: dict[str, Any] | None = None, + ): # 保留本模块的配置入口,方便调用方在测试或运行时注入 settings。 settings = get_settings() if not settings.openai_api_key: logger.error("OpenAI配置不完整:缺少 OPENAI_API_KEY。") raise ValueError("OpenAI配置不完整:缺少 OPENAI_API_KEY。") - super().__init__(model_name=model_name, provider="openai", settings=settings, protocol=protocol, options=options) + super().__init__( + model_name=model_name, + provider="openai", + settings=settings, + protocol=protocol, + options=options, + ) diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index c7e8974..5a5a514 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -51,6 +51,7 @@ logger = get_module_logger(__name__) + class OpenAICompatibleProvider(LargeLanguageModel, TextEmbeddingModel): """ 处理所有与OpenAI API格式兼容的提供商的通用逻辑。 @@ -59,8 +60,15 @@ class OpenAICompatibleProvider(LargeLanguageModel, TextEmbeddingModel): capabilities = frozenset( { - "chat", "stream", "messages", "multimodal", "tools", - "structured_output", "usage", "embedding", "responses", + "chat", + "stream", + "messages", + "multimodal", + "tools", + "structured_output", + "usage", + "embedding", + "responses", } ) # OpenAI's resource methods live on this shared adapter for code reuse, but @@ -76,9 +84,19 @@ class OpenAICompatibleProvider(LargeLanguageModel, TextEmbeddingModel): # all other compatible providers fail-closed. _OFFICIAL_OPENAI_RESOURCE_CAPABILITIES = frozenset( { - "files", "batches", "vector_stores", "models", "moderation", - "images", "audio", "videos", "uploads", "conversations", - "containers", "fine_tuning", "evals", + "files", + "batches", + "vector_stores", + "models", + "moderation", + "images", + "audio", + "videos", + "uploads", + "conversations", + "containers", + "fine_tuning", + "evals", } ) # These fields are accepted by OpenAI Chat Completions but are not part of @@ -119,29 +137,81 @@ class OpenAICompatibleProvider(LargeLanguageModel, TextEmbeddingModel): # reach the OpenAI SDK as an unknown keyword argument. _CHAT_MODEL_OPTION_KEYS = frozenset( { - "temperature", "max_tokens", "max_completion_tokens", "top_p", - "frequency_penalty", "presence_penalty", "n", "logit_bias", - "logprobs", "top_logprobs", "modalities", "audio", "prediction", - "web_search_options", "seed", "stop", "response_format", - "tool_choice", "metadata", "user", "reasoning_effort", - "stream_options", "parallel_tool_calls", "store", "service_tier", - "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", - "safety_identifier", "moderation", "verbosity", "extra_body", + "temperature", + "max_tokens", + "max_completion_tokens", + "top_p", + "frequency_penalty", + "presence_penalty", + "n", + "logit_bias", + "logprobs", + "top_logprobs", + "modalities", + "audio", + "prediction", + "web_search_options", + "seed", + "stop", + "response_format", + "tool_choice", + "metadata", + "user", + "reasoning_effort", + "stream_options", + "parallel_tool_calls", + "store", + "service_tier", + "prompt_cache_key", + "prompt_cache_options", + "prompt_cache_retention", + "safety_identifier", + "moderation", + "verbosity", + "extra_body", } ) _RESPONSES_MODEL_OPTION_KEYS = frozenset( { - "background", "context_management", "conversation", "include", - "max_output_tokens", "max_tool_calls", "metadata", "moderation", - "parallel_tool_calls", "previous_response_id", "prompt", - "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", - "reasoning", "reasoning_effort", "safety_identifier", "service_tier", - "store", "stream_options", "temperature", "text", "tool_choice", - "tools", "top_logprobs", "top_p", "truncation", "user", + "background", + "context_management", + "conversation", + "include", + "max_output_tokens", + "max_tool_calls", + "metadata", + "moderation", + "parallel_tool_calls", + "previous_response_id", + "prompt", + "prompt_cache_key", + "prompt_cache_options", + "prompt_cache_retention", + "reasoning", + "reasoning_effort", + "safety_identifier", + "service_tier", + "store", + "stream_options", + "temperature", + "text", + "tool_choice", + "tools", + "top_logprobs", + "top_p", + "truncation", + "user", # Portable/common names normalized by this adapter. - "max_tokens", "max_completion_tokens", "response_format", "stop", "seed", + "max_tokens", + "max_completion_tokens", + "response_format", + "stop", + "seed", "verbosity", - "top_k", "frequency_penalty", "presence_penalty", "extra_body", + "top_k", + "frequency_penalty", + "presence_penalty", + "extra_body", } ) _EMBEDDING_MODEL_OPTION_KEYS = frozenset( @@ -167,27 +237,27 @@ def __init__( self._options.get("server_verified_protocols", ()) ) settings = settings if settings is not None else get_settings() - + settings_prefix = provider.lower().replace("-", "_") api_key_name = f"{settings_prefix}_api_key" base_url_name = f"{settings_prefix}_base_url" - + self._api_key = getattr(settings, api_key_name, None) self._base_url = getattr(settings, base_url_name, None) if self._base_url is None: # OpenAI 旧配置字段使用 `openai_api_base`,继续兼容现有配置文件。 self._base_url = getattr(settings, f"{settings_prefix}_api_base", None) - + if provider in ["ollama", "lm-studio"] and not self._api_key: self._api_key = "no-key-required" - + if not self._api_key: logger.error(f"{provider} API Key 未设置。") raise ValueError(f"{api_key_name} is required for {provider}") if provider != "openai" and not self._base_url: logger.error(f"{provider} Base URL 未设置。") raise ValueError(f"{base_url_name} is required for {provider}") - + self._client: openai.OpenAI | None = None self._aclient: openai.AsyncOpenAI | None = None logger.info(f"初始化 OpenAICompatibleProvider ({provider}),模型: {model_name}") @@ -219,15 +289,11 @@ def _normalize_verified_protocols(cls, configured: Any) -> frozenset[str]: if isinstance(configured, str): configured = (configured,) elif not isinstance(configured, (list, tuple, set, frozenset)): - raise ValueError( - "server_verified_protocols 必须是字符串或协议序列。" - ) + raise ValueError("server_verified_protocols 必须是字符串或协议序列。") normalized: set[str] = set() for value in configured: if not isinstance(value, str) or not value.strip(): - raise ValueError( - "server_verified_protocols 中的协议必须是非空字符串。" - ) + raise ValueError("server_verified_protocols 中的协议必须是非空字符串。") normalized.add(cls._normalize_protocol(value)) return frozenset(normalized) @@ -238,7 +304,9 @@ def _get_client(self) -> openai.OpenAI: for key, value in self._options.items() if key in {"timeout", "max_retries"} } - self._client = openai.OpenAI(api_key=self._api_key, base_url=self._base_url, **client_options) + self._client = openai.OpenAI( + api_key=self._api_key, base_url=self._base_url, **client_options + ) return self._client def _get_aclient(self) -> openai.AsyncOpenAI: @@ -248,7 +316,9 @@ def _get_aclient(self) -> openai.AsyncOpenAI: for key, value in self._options.items() if key in {"timeout", "max_retries"} } - self._aclient = openai.AsyncOpenAI(api_key=self._api_key, base_url=self._base_url, **client_options) + self._aclient = openai.AsyncOpenAI( + api_key=self._api_key, base_url=self._base_url, **client_options + ) return self._aclient @staticmethod @@ -349,11 +419,7 @@ def _validate_model_options( allowed: frozenset[str], endpoint: str, ) -> None: - unknown = { - key: value - for key, value in options.items() - if key not in allowed - } + unknown = {key: value for key, value in options.items() if key not in allowed} reject_unsupported_kwargs(endpoint, unknown) def _background_poll_config(self) -> tuple[float, float]: @@ -414,7 +480,9 @@ def _poll_background_response(self, response: Any, params: Mapping[str, Any]) -> status = self._field(response, "status") return response - async def _poll_background_response_async(self, response: Any, params: Mapping[str, Any]) -> Any: + async def _poll_background_response_async( + self, response: Any, params: Mapping[str, Any] + ) -> Any: status = self._field(response, "status") if status not in {"queued", "in_progress", "pending"}: return response @@ -545,9 +613,7 @@ def _require_provider_resource(self, method_name: str) -> None: if provider_name == "openai" and not declared: declared = self._OFFICIAL_OPENAI_RESOURCE_CAPABILITIES if capability not in declared: - raise NotImplementedError( - f"{provider_name} 未声明 {capability} 资源能力。" - ) + raise NotImplementedError(f"{provider_name} 未声明 {capability} 资源能力。") def _chat_max_tokens_key(self) -> str: """返回当前 Chat Completions 端点的输出长度字段名。""" @@ -575,11 +641,7 @@ def _embedding_options(self) -> dict[str, Any]: f"{getattr(self, '_provider', 'OpenAI-compatible')} Embedding 模型 options", {key: value for key, value in options.items() if key not in allowed}, ) - return { - key: value - for key, value in options.items() - if key in supported - } + return {key: value for key, value in options.items() if key in supported} @staticmethod def _validate_embedding_kwargs(kwargs: Mapping[str, Any]) -> None: @@ -601,9 +663,7 @@ def _validate_embedding_kwargs(kwargs: Mapping[str, Any]) -> None: "server_verified_protocols", } unsupported = { - key: value - for key, value in kwargs.items() - if key not in allowed or key in client_only + key: value for key, value in kwargs.items() if key not in allowed or key in client_only } reject_unsupported_kwargs("OpenAI Embedding", unsupported) validate_secret_free_request_overrides( @@ -676,9 +736,7 @@ def _validated_extra_body( value = validate_secret_free_payload(value, endpoint, "extra_body") overlap = sorted(set(value).intersection(reserved)) if overlap: - raise ValueError( - f"{endpoint} extra_body 不允许覆盖请求字段: {', '.join(overlap)}" - ) + raise ValueError(f"{endpoint} extra_body 不允许覆盖请求字段: {', '.join(overlap)}") return dict(value) @staticmethod @@ -692,9 +750,7 @@ def _merge_extra_body( extension_values = dict(extensions or {}) overlap = sorted(set(configured_values).intersection(extension_values)) if overlap: - raise ValueError( - f"{endpoint} 模型 options 的扩展字段重复: {', '.join(overlap)}" - ) + raise ValueError(f"{endpoint} 模型 options 的扩展字段重复: {', '.join(overlap)}") return {**configured_values, **extension_values} @staticmethod @@ -707,16 +763,12 @@ def _merge_resource_kwargs( ) -> dict[str, Any]: """合并资源入口参数,拒绝调用方覆盖 Facade 已绑定的字段。""" fixed_values = { - key: value - for key, value in fixed.items() - if not omit_none or value is not None + key: value for key, value in fixed.items() if not omit_none or value is not None } override_values = dict(overrides or {}) overlap = sorted(set(fixed_values).intersection(override_values)) if overlap: - raise ValueError( - f"{operation} 资源 SDK 参数重复: {', '.join(overlap)}" - ) + raise ValueError(f"{operation} 资源 SDK 参数重复: {', '.join(overlap)}") return {**fixed_values, **override_values} def _build_chat_request( @@ -795,8 +847,7 @@ def _build_chat_request( configured_max_completion_tokens = configured_options.pop("max_completion_tokens", None) if configured_max_tokens is not None and configured_max_completion_tokens is not None: raise ValueError( - "Chat Completions options 不能同时设置 max_tokens 和 " - "max_completion_tokens。" + "Chat Completions options 不能同时设置 max_tokens 和 max_completion_tokens。" ) configured_extra_body = self._validated_extra_body( configured_options.pop("extra_body", None), @@ -821,13 +872,19 @@ def _build_chat_request( max_tokens_key = self._chat_max_tokens_key() if max_tokens is not None: request[max_tokens_key] = max_tokens - request.pop("max_tokens" if max_tokens_key != "max_tokens" else "max_completion_tokens", None) + request.pop( + "max_tokens" if max_tokens_key != "max_tokens" else "max_completion_tokens", None + ) elif configured_max_tokens is not None and max_tokens_key not in request: request[max_tokens_key] = configured_max_tokens - request.pop("max_tokens" if max_tokens_key != "max_tokens" else "max_completion_tokens", None) + request.pop( + "max_tokens" if max_tokens_key != "max_tokens" else "max_completion_tokens", None + ) elif configured_max_completion_tokens is not None and max_tokens_key not in request: request[max_tokens_key] = configured_max_completion_tokens - request.pop("max_tokens" if max_tokens_key != "max_tokens" else "max_completion_tokens", None) + request.pop( + "max_tokens" if max_tokens_key != "max_tokens" else "max_completion_tokens", None + ) if top_p is not None: request["top_p"] = top_p request_extensions = { @@ -865,9 +922,7 @@ def _build_chat_request( request.setdefault("extra_body", {})["repetition_penalty"] = repetition_penalty if top_k is not None and self._protocol == "chat_completions": if "top_k" in configured_explicit_extra_keys: - raise ValueError( - "Chat Completions top_k 与模型 options.extra_body 重复。" - ) + raise ValueError("Chat Completions top_k 与模型 options.extra_body 重复。") request.setdefault("extra_body", {})["top_k"] = top_k if seed is not None: if "seed" in configured_explicit_extra_keys: @@ -893,15 +948,11 @@ def _build_chat_request( effort = reasoning.get("effort") if isinstance(reasoning, Mapping) else None if effort is not None: if "reasoning" in configured_explicit_extra_keys: - raise ValueError( - "Chat Completions reasoning 与模型 options.extra_body 重复。" - ) + raise ValueError("Chat Completions reasoning 与模型 options.extra_body 重复。") request["reasoning_effort"] = effort else: if "reasoning" in configured_explicit_extra_keys: - raise ValueError( - "Chat Completions reasoning 与模型 options.extra_body 重复。" - ) + raise ValueError("Chat Completions reasoning 与模型 options.extra_body 重复。") request.setdefault("extra_body", {})["reasoning"] = reasoning if stream_options is not None: request["stream_options"] = stream_options @@ -941,22 +992,14 @@ def _convert_responses_tools(tools: list[dict[str, Any]] | None) -> list[dict[st native_tool = dict(tool) tool_type = native_tool.get("type") if not isinstance(tool_type, str) or not tool_type.strip(): - raise ValueError( - f"Responses 工具定义[{index}] 缺少有效 type。" - ) + raise ValueError(f"Responses 工具定义[{index}] 缺少有效 type。") if tool_type == "function": name = native_tool.get("name") if not isinstance(name, str) or not name.strip(): - raise ValueError( - f"Responses 工具定义[{index}] 缺少 name。" - ) - parameters = native_tool.get( - "parameters", {"type": "object", "properties": {}} - ) + raise ValueError(f"Responses 工具定义[{index}] 缺少 name。") + parameters = native_tool.get("parameters", {"type": "object", "properties": {}}) if not isinstance(parameters, Mapping): - raise ValueError( - f"Responses 工具定义[{index}].parameters 必须是对象。" - ) + raise ValueError(f"Responses 工具定义[{index}].parameters 必须是对象。") native_tool["parameters"] = dict(parameters) native_tool.setdefault("strict", False) converted.append(native_tool) @@ -964,19 +1007,13 @@ def _convert_responses_tools(tools: list[dict[str, Any]] | None) -> list[dict[st function = tool["function"] if not isinstance(function, Mapping): - raise ValueError( - f"Responses 工具定义[{index}].function 必须是对象。" - ) + raise ValueError(f"Responses 工具定义[{index}].function 必须是对象。") name = function.get("name") if not isinstance(name, str) or not name.strip(): raise ValueError(f"Responses 工具定义[{index}] 缺少 function.name。") - parameters = function.get( - "parameters", {"type": "object", "properties": {}} - ) + parameters = function.get("parameters", {"type": "object", "properties": {}}) if not isinstance(parameters, Mapping): - raise ValueError( - f"Responses 工具定义[{index}].function.parameters 必须是对象。" - ) + raise ValueError(f"Responses 工具定义[{index}].function.parameters 必须是对象。") response_tool: dict[str, Any] = { "type": "function", "name": name, @@ -1119,8 +1156,7 @@ def _build_responses_request( } if len(configured_limits) > 1: raise ValueError( - "Responses options 不能同时设置多个输出长度字段: " - + ", ".join(configured_limits) + "Responses options 不能同时设置多个输出长度字段: " + ", ".join(configured_limits) ) if configured_max_output_tokens is not None: configured_options.pop("max_output_tokens", None) @@ -1292,9 +1328,7 @@ def _build_responses_request( request["reasoning"] = reasoning elif configured_reasoning_effort is not None: if "reasoning" in configured_explicit_extra_keys: - raise ValueError( - "Responses reasoning_effort 与模型 options.extra_body 重复。" - ) + raise ValueError("Responses reasoning_effort 与模型 options.extra_body 重复。") request["reasoning"] = {"effort": configured_reasoning_effort} if store is not None: request["store"] = store @@ -1352,8 +1386,7 @@ def _build_responses_request( ) if configured_overlap: raise ValueError( - "Responses extra_body 与模型 options 重复: " - + ", ".join(configured_overlap) + "Responses extra_body 与模型 options 重复: " + ", ".join(configured_overlap) ) request.setdefault("extra_body", {}).update(request_extra_body) if request.get("extra_body"): @@ -1372,7 +1405,9 @@ def _convert_responses_format(response_format: dict[str, Any]) -> dict[str, Any] return { "format": { "type": "json_schema", - "name": schema.get("name", "response") if isinstance(schema, Mapping) else "response", + "name": schema.get("name", "response") + if isinstance(schema, Mapping) + else "response", "schema": json_schema or response_format.get("schema", schema), "strict": schema.get("strict", True) if isinstance(schema, Mapping) else True, } @@ -1424,7 +1459,9 @@ def _extract_tool_calls(cls, response: Any) -> list[dict[str, Any]]: "id": cls._field(call, "id"), "type": cls._field(call, "type", "function"), "name": cls._field(function, "name"), - "arguments": normalize_tool_arguments(cls._field(function, "arguments", "")), + "arguments": normalize_tool_arguments( + cls._field(function, "arguments", "") + ), } ) for item in cls._field(response, "output", []) or []: @@ -1568,17 +1605,13 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C if self._protocol == "responses": self._require_responses_resource("complete") params = self._build_responses_request(**request.to_invoke_kwargs()) - response = retry_sync_call( - lambda: self._get_client().responses.create(**params) - ) + response = retry_sync_call(lambda: self._get_client().responses.create(**params)) if params.get("background"): response = self._poll_background_response(response, params) self._raise_for_response_error(response) else: params = self._build_chat_request(**request.to_invoke_kwargs()) - response = retry_sync_call( - lambda: self._get_client().chat.completions.create(**params) - ) + response = retry_sync_call(lambda: self._get_client().chat.completions.create(**params)) self._raise_for_chat_response_error( response, provider=f"{getattr(self, '_provider', 'OpenAI-compatible')} Chat Completions", @@ -1587,7 +1620,9 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C finish_reason = ( self._field(response, "status") if self._protocol == "responses" - else self._field(choices[0], "finish_reason") if choices else None + else self._field(choices[0], "finish_reason") + if choices + else None ) return CompletionResult( text=self._extract_response_text(response), @@ -1600,7 +1635,9 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C raw=response, ) - async def acomplete(self, request: CompletionRequest | None = None, **kwargs: Any) -> CompletionResult: + async def acomplete( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> CompletionResult: """使用 AsyncOpenAI 聚合完整结果,保留 SDK 元数据。""" request = coerce_completion_request(request, kwargs, "OpenAI-compatible acomplete") request = request.copy_with(stream=False) @@ -1626,7 +1663,9 @@ async def acomplete(self, request: CompletionRequest | None = None, **kwargs: An finish_reason = ( self._field(response, "status") if self._protocol == "responses" - else self._field(choices[0], "finish_reason") if choices else None + else self._field(choices[0], "finish_reason") + if choices + else None ) return CompletionResult( text=self._extract_response_text(response), @@ -1825,7 +1864,9 @@ def _responses_stream_event( ) if event_type in {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}: delta = cls._field(event, "delta") - return StreamEvent(type="reasoning_delta", reasoning=delta if isinstance(delta, str) else "", raw=event) + return StreamEvent( + type="reasoning_delta", reasoning=delta if isinstance(delta, str) else "", raw=event + ) if event_type.endswith(".delta") and ( "function_call_arguments" in event_type or "custom_tool_call_input" in event_type ): @@ -1844,7 +1885,9 @@ def _responses_stream_event( tool_call={ "id": item_id or cls._field(event, "call_id") or metadata.get("id"), "call_id": call_id, - "index": output_index if output_index is not None else cls._field(event, "index"), + "index": output_index + if output_index is not None + else cls._field(event, "index"), "type": item_type, "name": cls._field(event, "name") or metadata.get("name"), "arguments": normalize_tool_arguments(cls._field(event, "delta", "")), @@ -1865,7 +1908,11 @@ def _responses_stream_event( or "function_call_arguments" in event_type or "custom_tool_call_input" in event_type ): - call_id = cls._field(item, "call_id") or cls._field(event, "call_id") or metadata.get("call_id") + call_id = ( + cls._field(item, "call_id") + or cls._field(event, "call_id") + or metadata.get("call_id") + ) output_index = cls._field(event, "output_index") if output_index is None: output_index = metadata.get("index") @@ -1885,9 +1932,15 @@ def _responses_stream_event( or cls._field(item, "id") or cls._field(event, "item_id") or metadata.get("id"), - "index": output_index if output_index is not None else cls._field(event, "index"), - "type": item_type if item_type in {"function_call", "custom_tool_call"} else "function_call", - "name": cls._field(item, "name") or cls._field(event, "name") or metadata.get("name"), + "index": output_index + if output_index is not None + else cls._field(event, "index"), + "type": item_type + if item_type in {"function_call", "custom_tool_call"} + else "function_call", + "name": cls._field(item, "name") + or cls._field(event, "name") + or metadata.get("name"), "arguments": normalize_tool_arguments(argument_value), } if call_id is not None: @@ -1900,7 +1953,10 @@ def _responses_stream_event( if event_type in {"error", "response.error"}: raise_for_stream_error_event(event, error_provider) if event_type in { - "response.completed", "response.incomplete", "response.failed", "response.cancelled" + "response.completed", + "response.incomplete", + "response.failed", + "response.cancelled", }: response = cls._field(event, "response") or event usage = cls._extract_usage(response) @@ -1997,8 +2053,12 @@ def _responses_completion_event( raw=converted.raw, ) - def stream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> Generator[StreamEvent, None, None]: - request = coerce_completion_request(request, kwargs, "OpenAI-compatible stream_events").copy_with( + def stream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> Generator[StreamEvent, None, None]: + request = coerce_completion_request( + request, kwargs, "OpenAI-compatible stream_events" + ).copy_with( stream=True, ) if self._protocol == "responses": @@ -2074,18 +2134,22 @@ def stream_events(self, request: CompletionRequest | None = None, **kwargs: Any) chunk, getattr(self, "_provider", "OpenAI-compatible") ), ): - raise_for_stream_error_event( - chunk, getattr(self, "_provider", "OpenAI-compatible") - ) + raise_for_stream_error_event(chunk, getattr(self, "_provider", "OpenAI-compatible")) for converted in self._chat_stream_events(chunk): if converted.type == "finish": terminal_seen = True - yield from self._chat_completion_events(converted, tool_calls, completed_chat_tool_calls) + yield from self._chat_completion_events( + converted, tool_calls, completed_chat_tool_calls + ) if not terminal_seen: raise RuntimeError("Chat Completions 流在 finish 事件之前结束。") - async def astream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> AsyncGenerator[StreamEvent, None]: - request = coerce_completion_request(request, kwargs, "OpenAI-compatible astream_events").copy_with( + async def astream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> AsyncGenerator[StreamEvent, None]: + request = coerce_completion_request( + request, kwargs, "OpenAI-compatible astream_events" + ).copy_with( stream=True, ) if self._protocol == "responses": @@ -2160,13 +2224,13 @@ async def astream_events(self, request: CompletionRequest | None = None, **kwarg chunk, getattr(self, "_provider", "OpenAI-compatible") ), ): - raise_for_stream_error_event( - chunk, getattr(self, "_provider", "OpenAI-compatible") - ) + raise_for_stream_error_event(chunk, getattr(self, "_provider", "OpenAI-compatible")) for converted in self._chat_stream_events(chunk): if converted.type == "finish": terminal_seen = True - for event in self._chat_completion_events(converted, tool_calls, completed_chat_tool_calls): + for event in self._chat_completion_events( + converted, tool_calls, completed_chat_tool_calls + ): yield event if not terminal_seen: raise RuntimeError("Chat Completions 异步流在 finish 事件之前结束。") @@ -2262,9 +2326,7 @@ def _error_message(cls, error: Any, default: str) -> str: def _raise_for_response_error(cls, response: Any) -> None: error = cls._field(response, "error") if error: - raise RuntimeError( - f"Responses API 返回错误: {cls._error_message(error, '未知错误。')}" - ) + raise RuntimeError(f"Responses API 返回错误: {cls._error_message(error, '未知错误。')}") status = cls._field(response, "status") if status not in {"failed", "incomplete", "cancelled"}: @@ -2287,7 +2349,10 @@ def _stream_delta(cls, event: Any) -> str: if event_type in {"error", "response.error"}: raise_for_stream_error_event(event, "Responses API") if event_type in { - "response.completed", "response.failed", "response.incomplete", "response.cancelled" + "response.completed", + "response.failed", + "response.incomplete", + "response.cancelled", }: response = cls._field(event, "response") or event cls._raise_for_response_error(response) @@ -2306,9 +2371,7 @@ def _invoke_responses(self, request: dict[str, Any]) -> Iterator[str]: if event_type == "response.refusal.delta": refusal = self._field(event, "delta") if refusal: - raise RuntimeError( - f"{self._provider} Responses 返回拒答: {refusal}" - ) + raise RuntimeError(f"{self._provider} Responses 返回拒答: {refusal}") delta = self._stream_delta(event) if delta: yield delta @@ -2339,9 +2402,7 @@ async def _ainvoke_responses(self, request: dict[str, Any]) -> AsyncIterator[str if event_type == "response.refusal.delta": refusal = self._field(event, "delta") if refusal: - raise RuntimeError( - f"{self._provider} Responses 返回拒答: {refusal}" - ) + raise RuntimeError(f"{self._provider} Responses 返回拒答: {refusal}") delta = self._stream_delta(event) if delta: yield delta @@ -2394,25 +2455,52 @@ def invoke( ) client = self._get_client() start_time = time.perf_counter() - + try: if self._protocol == "responses": yield from self._invoke_responses( - self._build_responses_request(prompt, system_prompt, tools, temperature, stream, - messages=messages, max_tokens=max_tokens, top_p=top_p, - top_k=top_k, seed=seed, - stop=stop, response_format=response_format, tool_choice=tool_choice, - extra_body=extra_body, metadata=metadata, user=user, timeout=timeout, - **kwargs) + self._build_responses_request( + prompt, + system_prompt, + tools, + temperature, + stream, + messages=messages, + max_tokens=max_tokens, + top_p=top_p, + top_k=top_k, + seed=seed, + stop=stop, + response_format=response_format, + tool_choice=tool_choice, + extra_body=extra_body, + metadata=metadata, + user=user, + timeout=timeout, + **kwargs, + ) ) return request = self._build_chat_request( - prompt, system_prompt, tools, temperature, stream, - messages=messages, max_tokens=max_tokens, top_p=top_p, stop=stop, - top_k=top_k, seed=seed, response_format=response_format, - tool_choice=tool_choice, extra_body=extra_body, metadata=metadata, - user=user, timeout=timeout, **kwargs, + prompt, + system_prompt, + tools, + temperature, + stream, + messages=messages, + max_tokens=max_tokens, + top_p=top_p, + stop=stop, + top_k=top_k, + seed=seed, + response_format=response_format, + tool_choice=tool_choice, + extra_body=extra_body, + metadata=metadata, + user=user, + timeout=timeout, + **kwargs, ) if stream: terminal_seen = False @@ -2447,9 +2535,11 @@ def invoke( content = self._field(message, "content") if content: yield content_to_text(content) - + duration = time.perf_counter() - start_time - logger.info(f"{self._provider} LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s") + logger.info( + f"{self._provider} LLM ({self._model_name}) 调用完成,耗时: {duration:.2f}s" + ) except Exception as e: error_text = redact_sensitive_text(str(e)) logger.exception( @@ -2493,26 +2583,53 @@ async def ainvoke( ) aclient = self._get_aclient() start_time = time.perf_counter() - + try: if self._protocol == "responses": async for chunk in self._ainvoke_responses( - self._build_responses_request(prompt, system_prompt, tools, temperature, stream, - messages=messages, max_tokens=max_tokens, top_p=top_p, - top_k=top_k, seed=seed, - stop=stop, response_format=response_format, tool_choice=tool_choice, - extra_body=extra_body, metadata=metadata, user=user, timeout=timeout, - **kwargs) + self._build_responses_request( + prompt, + system_prompt, + tools, + temperature, + stream, + messages=messages, + max_tokens=max_tokens, + top_p=top_p, + top_k=top_k, + seed=seed, + stop=stop, + response_format=response_format, + tool_choice=tool_choice, + extra_body=extra_body, + metadata=metadata, + user=user, + timeout=timeout, + **kwargs, + ) ): yield chunk return request = self._build_chat_request( - prompt, system_prompt, tools, temperature, stream, - messages=messages, max_tokens=max_tokens, top_p=top_p, stop=stop, - top_k=top_k, seed=seed, response_format=response_format, - tool_choice=tool_choice, extra_body=extra_body, metadata=metadata, - user=user, timeout=timeout, **kwargs, + prompt, + system_prompt, + tools, + temperature, + stream, + messages=messages, + max_tokens=max_tokens, + top_p=top_p, + stop=stop, + top_k=top_k, + seed=seed, + response_format=response_format, + tool_choice=tool_choice, + extra_body=extra_body, + metadata=metadata, + user=user, + timeout=timeout, + **kwargs, ) if stream: terminal_seen = False @@ -2533,7 +2650,9 @@ async def ainvoke( if not terminal_seen: raise RuntimeError("Chat Completions 异步流在 finish 事件之前结束。") else: - response = await retry_async_call(lambda: aclient.chat.completions.create(**request)) + response = await retry_async_call( + lambda: aclient.chat.completions.create(**request) + ) self._raise_for_chat_response_error( response, provider=f"{getattr(self, '_provider', 'OpenAI-compatible')} Chat Completions", @@ -2547,9 +2666,11 @@ async def ainvoke( content = self._field(message, "content") if content: yield content_to_text(content) - + duration = time.perf_counter() - start_time - logger.info(f"{self._provider} LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s") + logger.info( + f"{self._provider} LLM ({self._model_name}) 异步调用完成,耗时: {duration:.2f}s" + ) except Exception as e: error_text = redact_sensitive_text(str(e)) logger.exception( @@ -2564,7 +2685,7 @@ async def ainvoke( stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception(is_retryable_error), - reraise=True + reraise=True, ) def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: """同步向量化文档。""" @@ -2575,7 +2696,7 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: raise ValueError(f"{self._provider} Embedding 不允许覆盖请求字段: {', '.join(overlap)}") self._validate_embedding_kwargs(kwargs) client = self._get_client() - + try: request: dict[str, Any] = {"input": texts, "model": self._model_name} configured_options = self._embedding_options() @@ -2622,7 +2743,7 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception(is_retryable_error), - reraise=True + reraise=True, ) async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: """异步向量化文档。""" @@ -2633,7 +2754,7 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f raise ValueError(f"{self._provider} Embedding 不允许覆盖请求字段: {', '.join(overlap)}") self._validate_embedding_kwargs(kwargs) aclient = self._get_aclient() - + try: request: dict[str, Any] = {"input": texts, "model": self._model_name} configured_options = self._embedding_options() @@ -2664,7 +2785,9 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f response = await self._resolve_async_result(aclient.embeddings.create(**request)) embeddings = [normalize_embedding_vector(item.embedding) for item in response.data] duration = time.perf_counter() - start_time - logger.info(f"{self._provider} 嵌入 ({self._model_name}) 异步完成,耗时: {duration:.2f}s") + logger.info( + f"{self._provider} 嵌入 ({self._model_name}) 异步完成,耗时: {duration:.2f}s" + ) return embeddings except Exception as e: error_text = redact_sensitive_text(str(e)) @@ -2706,9 +2829,7 @@ def file_content(self, file_id: str, **kwargs: Any) -> Any: files = getattr(self._get_client(), "files", None) method = getattr(files, "content", None) if not callable(method): - raise NotImplementedError( - "当前 OpenAI SDK 不提供文件内容读取资源(files.content)。" - ) + raise NotImplementedError("当前 OpenAI SDK 不提供文件内容读取资源(files.content)。") return self._call_sdk_resource( method, args=(file_id,), @@ -2756,9 +2877,7 @@ def wait_for_file( } return self._call_sdk_resource( self._get_client().files.wait_for_processing, - kwargs=self._merge_resource_kwargs( - {"id": file_id}, poll_kwargs, "OpenAI 文件等待" - ), + kwargs=self._merge_resource_kwargs({"id": file_id}, poll_kwargs, "OpenAI 文件等待"), operation="OpenAI 文件等待", ) @@ -2872,7 +2991,9 @@ def stream_responses(self, request: CompletionRequest | None = None, **kwargs: A if request is not None and kwargs: raise ValueError("stream_responses 不能同时传 request 和原生关键字参数。") if request is not None: - params = self._build_responses_request(**request.copy_with(stream=True).to_invoke_kwargs()) + params = self._build_responses_request( + **request.copy_with(stream=True).to_invoke_kwargs() + ) params.pop("stream", None) else: params = dict(kwargs) @@ -2896,15 +3017,29 @@ def _build_token_count_request(self, request: CompletionRequest) -> dict[str, An unsupported = { key: value for key, value in request.to_invoke_kwargs().items() - if key not in { - "prompt", "system_prompt", "messages", "tools", "tool_choice", - "parallel_tool_calls", "reasoning", "previous_response_id", - "personality", "response_format", "truncation", "conversation", - "extra_headers", "extra_query", "extra_body", "timeout", + if key + not in { + "prompt", + "system_prompt", + "messages", + "tools", + "tool_choice", + "parallel_tool_calls", + "reasoning", + "previous_response_id", + "personality", + "response_format", + "truncation", + "conversation", + "extra_headers", + "extra_query", + "extra_body", + "timeout", # Shared generation defaults are not part of the token-count # request, but accepting and dropping them is necessary for # CompletionRequest compatibility. They are never forwarded. - "stream", "temperature", + "stream", + "temperature", } } reject_unsupported_kwargs("OpenAI Responses input token count", unsupported) @@ -2983,7 +3118,9 @@ def list_vector_stores(self, **kwargs: Any) -> Any: operation="OpenAI Vector Store 列表", ) - def search_vector_store(self, vector_store_id: str, query: str | list[str], **kwargs: Any) -> Any: + def search_vector_store( + self, vector_store_id: str, query: str | list[str], **kwargs: Any + ) -> Any: return self._call_sdk_resource( self._get_client().vector_stores.search, args=(vector_store_id,), @@ -3019,7 +3156,9 @@ def create_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: operation="OpenAI Vector Store 文件创建", ) - def create_vector_store_file_and_poll(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: + def create_vector_store_file_and_poll( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: return self._call_sdk_resource( self._get_client().vector_stores.files.create_and_poll, args=(file_id,), @@ -3042,9 +3181,7 @@ def upload_vector_store_file( """上传并挂载向量库文件,仅转发 SDK 支持的分块策略。""" reject_unsupported_kwargs("OpenAI 向量库文件上传", kwargs) upload_kwargs = ( - {"chunking_strategy": chunking_strategy} - if chunking_strategy is not None - else {} + {"chunking_strategy": chunking_strategy} if chunking_strategy is not None else {} ) return self._call_sdk_resource( self._get_client().vector_stores.files.upload, @@ -3183,9 +3320,7 @@ def poll_vector_store_file( args=(file_id,), kwargs=self._merge_resource_kwargs( {"vector_store_id": vector_store_id}, - {"poll_interval_ms": poll_interval_ms} - if poll_interval_ms is not None - else {}, + {"poll_interval_ms": poll_interval_ms} if poll_interval_ms is not None else {}, "OpenAI Vector Store 文件轮询", ), operation="OpenAI Vector Store 文件轮询", @@ -3207,7 +3342,9 @@ def create_vector_store_file_batch_and_poll(self, vector_store_id: str, **kwargs operation="OpenAI Vector Store 文件批次创建轮询", ) - def retrieve_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + def retrieve_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return self._call_sdk_resource( self._get_client().vector_stores.file_batches.retrieve, args=(batch_id,), @@ -3219,7 +3356,9 @@ def retrieve_vector_store_file_batch(self, vector_store_id: str, batch_id: str, operation="OpenAI Vector Store 文件批次读取", ) - def cancel_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + def cancel_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return self._call_sdk_resource( self._get_client().vector_stores.file_batches.cancel, args=(batch_id,), @@ -3246,15 +3385,15 @@ def poll_vector_store_file_batch( args=(batch_id,), kwargs=self._merge_resource_kwargs( {"vector_store_id": vector_store_id}, - {"poll_interval_ms": poll_interval_ms} - if poll_interval_ms is not None - else {}, + {"poll_interval_ms": poll_interval_ms} if poll_interval_ms is not None else {}, "OpenAI Vector Store 文件批次轮询", ), operation="OpenAI Vector Store 文件批次轮询", ) - def list_vector_store_file_batch_files(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + def list_vector_store_file_batch_files( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return self._call_sdk_resource( self._get_client().vector_stores.file_batches.list_files, args=(batch_id,), @@ -3324,18 +3463,14 @@ def delete_model(self, model: str, **kwargs: Any) -> Any: def create_moderation(self, input: Any, **kwargs: Any) -> Any: return self._call_sdk_resource( self._get_client().moderations.create, - kwargs=self._merge_resource_kwargs( - {"input": input}, kwargs, "OpenAI Moderation 创建" - ), + kwargs=self._merge_resource_kwargs({"input": input}, kwargs, "OpenAI Moderation 创建"), operation="OpenAI Moderation 创建", ) def generate_image(self, prompt: str, **kwargs: Any) -> Any: return self._call_sdk_resource( self._get_client().images.generate, - kwargs=self._merge_resource_kwargs( - {"prompt": prompt}, kwargs, "OpenAI 图片生成" - ), + kwargs=self._merge_resource_kwargs({"prompt": prompt}, kwargs, "OpenAI 图片生成"), operation="OpenAI 图片生成", ) @@ -3351,9 +3486,7 @@ def edit_image(self, image: Any, prompt: str, **kwargs: Any) -> Any: def create_image_variation(self, image: Any, **kwargs: Any) -> Any: return self._call_sdk_resource( self._get_client().images.create_variation, - kwargs=self._merge_resource_kwargs( - {"image": image}, kwargs, "OpenAI 图片变体" - ), + kwargs=self._merge_resource_kwargs({"image": image}, kwargs, "OpenAI 图片变体"), operation="OpenAI 图片变体", ) @@ -3468,9 +3601,7 @@ def remix_video(self, video_id: str, prompt: str, **kwargs: Any) -> Any: return self._call_sdk_resource( self._get_client().videos.remix, args=(video_id,), - kwargs=self._merge_resource_kwargs( - {"prompt": prompt}, kwargs, "OpenAI 视频混剪" - ), + kwargs=self._merge_resource_kwargs({"prompt": prompt}, kwargs, "OpenAI 视频混剪"), operation="OpenAI 视频混剪", ) @@ -3488,7 +3619,9 @@ def poll_video( args=(video_id,), kwargs={ "poll_interval_ms": poll_interval_ms, - } if poll_interval_ms is not None else {}, + } + if poll_interval_ms is not None + else {}, operation="OpenAI 视频轮询", ) @@ -3519,9 +3652,7 @@ def create_upload_part(self, upload_id: str, data: Any, **kwargs: Any) -> Any: return self._call_sdk_resource( self._get_client().uploads.parts.create, args=(upload_id,), - kwargs=self._merge_resource_kwargs( - {"data": data}, kwargs, "OpenAI Upload 分片创建" - ), + kwargs=self._merge_resource_kwargs({"data": data}, kwargs, "OpenAI Upload 分片创建"), operation="OpenAI Upload 分片创建", ) @@ -3766,8 +3897,7 @@ def container_file_content(self, container_id: str, file_id: str, **kwargs: Any) method = getattr(content, "retrieve", None) if not callable(method): raise NotImplementedError( - "当前 OpenAI SDK 不提供容器文件内容读取资源 " - "(containers.files.content.retrieve)。" + "当前 OpenAI SDK 不提供容器文件内容读取资源 (containers.files.content.retrieve)。" ) return self._call_sdk_resource( method, @@ -3972,9 +4102,7 @@ async def async_file_content(self, file_id: str, **kwargs: Any) -> Any: files = getattr(self._get_aclient(), "files", None) method = getattr(files, "content", None) if not callable(method): - raise NotImplementedError( - "当前 OpenAI SDK 不提供文件内容读取资源(files.content)。" - ) + raise NotImplementedError("当前 OpenAI SDK 不提供文件内容读取资源(files.content)。") return await self._call_async_sdk_resource( method, args=(file_id,), @@ -4022,13 +4150,13 @@ async def async_wait_for_file( } return await self._call_async_sdk_resource( self._get_aclient().files.wait_for_processing, - kwargs=self._merge_resource_kwargs( - {"id": file_id}, poll_kwargs, "OpenAI 文件等待" - ), + kwargs=self._merge_resource_kwargs({"id": file_id}, poll_kwargs, "OpenAI 文件等待"), operation="OpenAI 文件等待", ) - async def async_create_batch(self, input_file_id: str, endpoint: str | None = None, **kwargs: Any) -> Any: + async def async_create_batch( + self, input_file_id: str, endpoint: str | None = None, **kwargs: Any + ) -> Any: if self._protocol == "responses" and ( endpoint is None or endpoint.rstrip("/") == "/v1/responses" ): @@ -4115,13 +4243,17 @@ def async_connect_responses(self, **kwargs: Any) -> Any: operation="OpenAI Responses 连接", ) - def async_stream_responses(self, request: CompletionRequest | None = None, **kwargs: Any) -> Any: + def async_stream_responses( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> Any: """返回 AsyncOpenAI Responses 流上下文管理器。""" self._require_responses_resource("stream_responses") if request is not None and kwargs: raise ValueError("async_stream_responses 不能同时传 request 和原生关键字参数。") if request is not None: - params = self._build_responses_request(**request.copy_with(stream=True).to_invoke_kwargs()) + params = self._build_responses_request( + **request.copy_with(stream=True).to_invoke_kwargs() + ) params.pop("stream", None) else: params = dict(kwargs) @@ -4132,8 +4264,12 @@ def async_stream_responses(self, request: CompletionRequest | None = None, **kwa operation="OpenAI Responses 流", ) - async def async_count_input_tokens(self, request: CompletionRequest | None = None, **kwargs: Any) -> int: - request = coerce_completion_request(request, kwargs, "OpenAI-compatible async_count_input_tokens") + async def async_count_input_tokens( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> int: + request = coerce_completion_request( + request, kwargs, "OpenAI-compatible async_count_input_tokens" + ) self._require_responses_resource("count_input_tokens") params = self._build_token_count_request(request) response = await self._call_async_sdk_resource( @@ -4168,7 +4304,9 @@ async def async_list_vector_stores(self, **kwargs: Any) -> Any: operation="OpenAI Vector Store 列表", ) - async def async_search_vector_store(self, vector_store_id: str, query: Any, **kwargs: Any) -> Any: + async def async_search_vector_store( + self, vector_store_id: str, query: Any, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.search, args=(vector_store_id,), @@ -4194,7 +4332,9 @@ async def async_update_vector_store(self, vector_store_id: str, **kwargs: Any) - operation="OpenAI Vector Store 更新", ) - async def async_create_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: + async def async_create_vector_store_file( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.files.create, args=(vector_store_id,), @@ -4204,7 +4344,9 @@ async def async_create_vector_store_file(self, vector_store_id: str, file_id: st operation="OpenAI Vector Store 文件创建", ) - async def async_create_vector_store_file_and_poll(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: + async def async_create_vector_store_file_and_poll( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.files.create_and_poll, args=(file_id,), @@ -4227,9 +4369,7 @@ async def async_upload_vector_store_file( """异步上传并挂载向量库文件,仅转发 SDK 支持的分块策略。""" reject_unsupported_kwargs("OpenAI 向量库文件上传", kwargs) upload_kwargs = ( - {"chunking_strategy": chunking_strategy} - if chunking_strategy is not None - else {} + {"chunking_strategy": chunking_strategy} if chunking_strategy is not None else {} ) return await self._call_async_sdk_resource( self._get_aclient().vector_stores.files.upload, @@ -4272,7 +4412,9 @@ async def async_upload_vector_store_file_and_poll( operation="OpenAI Vector Store 文件上传轮询", ) - async def async_retrieve_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: + async def async_retrieve_vector_store_file( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.files.retrieve, args=(file_id,), @@ -4329,7 +4471,9 @@ async def async_update_vector_store_file( operation="OpenAI Vector Store 文件更新", ) - async def async_delete_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: + async def async_delete_vector_store_file( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.files.delete, args=(file_id,), @@ -4341,7 +4485,9 @@ async def async_delete_vector_store_file(self, vector_store_id: str, file_id: st operation="OpenAI Vector Store 文件删除", ) - async def async_vector_store_file_content(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: + async def async_vector_store_file_content( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.files.content, args=(file_id,), @@ -4368,15 +4514,15 @@ async def async_poll_vector_store_file( args=(file_id,), kwargs=self._merge_resource_kwargs( {"vector_store_id": vector_store_id}, - {"poll_interval_ms": poll_interval_ms} - if poll_interval_ms is not None - else {}, + {"poll_interval_ms": poll_interval_ms} if poll_interval_ms is not None else {}, "OpenAI Vector Store 文件轮询", ), operation="OpenAI Vector Store 文件轮询", ) - async def async_create_vector_store_file_batch(self, vector_store_id: str, **kwargs: Any) -> Any: + async def async_create_vector_store_file_batch( + self, vector_store_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.file_batches.create, args=(vector_store_id,), @@ -4384,7 +4530,9 @@ async def async_create_vector_store_file_batch(self, vector_store_id: str, **kwa operation="OpenAI Vector Store 文件批次创建", ) - async def async_create_vector_store_file_batch_and_poll(self, vector_store_id: str, **kwargs: Any) -> Any: + async def async_create_vector_store_file_batch_and_poll( + self, vector_store_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.file_batches.create_and_poll, args=(vector_store_id,), @@ -4392,7 +4540,9 @@ async def async_create_vector_store_file_batch_and_poll(self, vector_store_id: s operation="OpenAI Vector Store 文件批次创建轮询", ) - async def async_retrieve_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + async def async_retrieve_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.file_batches.retrieve, args=(batch_id,), @@ -4404,7 +4554,9 @@ async def async_retrieve_vector_store_file_batch(self, vector_store_id: str, bat operation="OpenAI Vector Store 文件批次读取", ) - async def async_cancel_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + async def async_cancel_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.file_batches.cancel, args=(batch_id,), @@ -4431,15 +4583,15 @@ async def async_poll_vector_store_file_batch( args=(batch_id,), kwargs=self._merge_resource_kwargs( {"vector_store_id": vector_store_id}, - {"poll_interval_ms": poll_interval_ms} - if poll_interval_ms is not None - else {}, + {"poll_interval_ms": poll_interval_ms} if poll_interval_ms is not None else {}, "OpenAI Vector Store 文件批次轮询", ), operation="OpenAI Vector Store 文件批次轮询", ) - async def async_list_vector_store_file_batch_files(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + async def async_list_vector_store_file_batch_files( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().vector_stores.file_batches.list_files, args=(batch_id,), @@ -4474,9 +4626,7 @@ async def async_upload_vector_store_file_batch_and_poll( }.items() if value is not None } - upload_and_poll = cast( - Any, self._get_aclient().vector_stores.file_batches.upload_and_poll - ) + upload_and_poll = cast(Any, self._get_aclient().vector_stores.file_batches.upload_and_poll) return await self._call_async_sdk_resource( upload_and_poll, kwargs=self._merge_resource_kwargs( @@ -4511,18 +4661,14 @@ async def async_delete_model(self, model: str, **kwargs: Any) -> Any: async def async_create_moderation(self, input: Any, **kwargs: Any) -> Any: return await self._call_async_sdk_resource( self._get_aclient().moderations.create, - kwargs=self._merge_resource_kwargs( - {"input": input}, kwargs, "OpenAI Moderation 创建" - ), + kwargs=self._merge_resource_kwargs({"input": input}, kwargs, "OpenAI Moderation 创建"), operation="OpenAI Moderation 创建", ) async def async_generate_image(self, prompt: str, **kwargs: Any) -> Any: return await self._call_async_sdk_resource( self._get_aclient().images.generate, - kwargs=self._merge_resource_kwargs( - {"prompt": prompt}, kwargs, "OpenAI 图片生成" - ), + kwargs=self._merge_resource_kwargs({"prompt": prompt}, kwargs, "OpenAI 图片生成"), operation="OpenAI 图片生成", ) @@ -4538,9 +4684,7 @@ async def async_edit_image(self, image: Any, prompt: str, **kwargs: Any) -> Any: async def async_create_image_variation(self, image: Any, **kwargs: Any) -> Any: return await self._call_async_sdk_resource( self._get_aclient().images.create_variation, - kwargs=self._merge_resource_kwargs( - {"image": image}, kwargs, "OpenAI 图片变体" - ), + kwargs=self._merge_resource_kwargs({"image": image}, kwargs, "OpenAI 图片变体"), operation="OpenAI 图片变体", ) @@ -4657,9 +4801,7 @@ async def async_remix_video(self, video_id: str, prompt: str, **kwargs: Any) -> return await self._call_async_sdk_resource( self._get_aclient().videos.remix, args=(video_id,), - kwargs=self._merge_resource_kwargs( - {"prompt": prompt}, kwargs, "OpenAI 视频混剪" - ), + kwargs=self._merge_resource_kwargs({"prompt": prompt}, kwargs, "OpenAI 视频混剪"), operation="OpenAI 视频混剪", ) @@ -4677,7 +4819,9 @@ async def async_poll_video( args=(video_id,), kwargs={ "poll_interval_ms": poll_interval_ms, - } if poll_interval_ms is not None else {}, + } + if poll_interval_ms is not None + else {}, operation="OpenAI 视频轮询", ) @@ -4708,9 +4852,7 @@ async def async_create_upload_part(self, upload_id: str, data: Any, **kwargs: An return await self._call_async_sdk_resource( self._get_aclient().uploads.parts.create, args=(upload_id,), - kwargs=self._merge_resource_kwargs( - {"data": data}, kwargs, "OpenAI Upload 分片创建" - ), + kwargs=self._merge_resource_kwargs({"data": data}, kwargs, "OpenAI Upload 分片创建"), operation="OpenAI Upload 分片创建", ) @@ -4830,7 +4972,9 @@ async def async_list_conversation_items(self, conversation_id: str, **kwargs: An operation="OpenAI Conversation 项目列表", ) - async def async_create_conversation_items(self, conversation_id: str, items: Any, **kwargs: Any) -> Any: + async def async_create_conversation_items( + self, conversation_id: str, items: Any, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().conversations.items.create, args=(conversation_id,), @@ -4840,7 +4984,9 @@ async def async_create_conversation_items(self, conversation_id: str, items: Any operation="OpenAI Conversation 项目创建", ) - async def async_retrieve_conversation_item(self, conversation_id: str, item_id: str, **kwargs: Any) -> Any: + async def async_retrieve_conversation_item( + self, conversation_id: str, item_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().conversations.items.retrieve, args=(item_id,), @@ -4852,7 +4998,9 @@ async def async_retrieve_conversation_item(self, conversation_id: str, item_id: operation="OpenAI Conversation 项目读取", ) - async def async_delete_conversation_item(self, conversation_id: str, item_id: str, **kwargs: Any) -> Any: + async def async_delete_conversation_item( + self, conversation_id: str, item_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().conversations.items.delete, args=(item_id,), @@ -4925,7 +5073,9 @@ async def async_list_container_files(self, container_id: str, **kwargs: Any) -> operation="OpenAI Container 文件列表", ) - async def async_retrieve_container_file(self, container_id: str, file_id: str, **kwargs: Any) -> Any: + async def async_retrieve_container_file( + self, container_id: str, file_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().containers.files.retrieve, args=(file_id,), @@ -4937,7 +5087,9 @@ async def async_retrieve_container_file(self, container_id: str, file_id: str, * operation="OpenAI Container 文件读取", ) - async def async_delete_container_file(self, container_id: str, file_id: str, **kwargs: Any) -> Any: + async def async_delete_container_file( + self, container_id: str, file_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().containers.files.delete, args=(file_id,), @@ -4949,14 +5101,15 @@ async def async_delete_container_file(self, container_id: str, file_id: str, **k operation="OpenAI Container 文件删除", ) - async def async_container_file_content(self, container_id: str, file_id: str, **kwargs: Any) -> Any: + async def async_container_file_content( + self, container_id: str, file_id: str, **kwargs: Any + ) -> Any: files = getattr(getattr(self._get_aclient(), "containers", None), "files", None) content = getattr(files, "content", None) method = getattr(content, "retrieve", None) if not callable(method): raise NotImplementedError( - "当前 OpenAI SDK 不提供容器文件内容读取资源 " - "(containers.files.content.retrieve)。" + "当前 OpenAI SDK 不提供容器文件内容读取资源 (containers.files.content.retrieve)。" ) return await self._call_async_sdk_resource( method, @@ -5107,7 +5260,9 @@ async def async_delete_eval_run(self, eval_id: str, run_id: str, **kwargs: Any) operation="OpenAI Eval Run 删除", ) - async def async_list_eval_run_output_items(self, eval_id: str, run_id: str, **kwargs: Any) -> Any: + async def async_list_eval_run_output_items( + self, eval_id: str, run_id: str, **kwargs: Any + ) -> Any: return await self._call_async_sdk_resource( self._get_aclient().evals.runs.output_items.list, args=(run_id,), diff --git a/src/providers/qwen.py b/src/providers/qwen.py index e9fa2ac..fa7f194 100644 --- a/src/providers/qwen.py +++ b/src/providers/qwen.py @@ -9,5 +9,10 @@ class QwenProvider(OpenAICompatibleProvider): 通过继承OpenAICompatibleProvider来复用与OpenAI API兼容的逻辑。 """ - def __init__(self, model_name: str, protocol: str = "chat_completions", options: dict[str, Any] | None = None): + def __init__( + self, + model_name: str, + protocol: str = "chat_completions", + options: dict[str, Any] | None = None, + ): super().__init__(model_name=model_name, provider="qwen", protocol=protocol, options=options) diff --git a/src/providers/resources.py b/src/providers/resources.py index 2523705..548fb92 100644 --- a/src/providers/resources.py +++ b/src/providers/resources.py @@ -111,18 +111,13 @@ def _wrap_native_resource_value(value: Any, provider: Any, path: str) -> Any: def _resource_provider_label(provider: Any) -> str: - return str( - getattr(provider, "_provider", None) - or provider.__class__.__name__ - ) + return str(getattr(provider, "_provider", None) or provider.__class__.__name__) def _guard_resource_method(method: _FacadeMethod) -> _FacadeMethod: """在所有显式 Facade 方法前统一校验 SDK 扩展参数。""" - allowed_business_parameters = frozenset( - getattr(method, "_resource_business_parameters", ()) - ) + allowed_business_parameters = frozenset(getattr(method, "_resource_business_parameters", ())) def sanitize_call( self: Any, @@ -147,9 +142,9 @@ def sanitize_call( elif parameter.kind is inspect.Parameter.VAR_KEYWORD: bound.arguments[name] = validate_secret_free_resource_kwargs(value, label) else: - bound.arguments[name] = validate_secret_free_resource_kwargs( - {name: value}, label - )[name] + bound.arguments[name] = validate_secret_free_resource_kwargs({name: value}, label)[ + name + ] return bound.args[1:], dict(bound.kwargs) if inspect.iscoroutinefunction(method): @@ -381,13 +376,17 @@ def update_vector_store(self, vector_store_id: str, **kwargs: Any) -> Any: def create_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: return self.provider.create_vector_store_file(vector_store_id, file_id, **kwargs) - def create_vector_store_file_and_poll(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: + def create_vector_store_file_and_poll( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: return self.provider.create_vector_store_file_and_poll(vector_store_id, file_id, **kwargs) def upload_vector_store_file(self, vector_store_id: str, file: Any, **kwargs: Any) -> Any: return self.provider.upload_vector_store_file(vector_store_id, file, **kwargs) - def upload_vector_store_file_and_poll(self, vector_store_id: str, file: Any, **kwargs: Any) -> Any: + def upload_vector_store_file_and_poll( + self, vector_store_id: str, file: Any, **kwargs: Any + ) -> Any: return self.provider.upload_vector_store_file_and_poll(vector_store_id, file, **kwargs) def retrieve_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: @@ -414,20 +413,32 @@ def create_vector_store_file_batch(self, vector_store_id: str, **kwargs: Any) -> def create_vector_store_file_batch_and_poll(self, vector_store_id: str, **kwargs: Any) -> Any: return self.provider.create_vector_store_file_batch_and_poll(vector_store_id, **kwargs) - def retrieve_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + def retrieve_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return self.provider.retrieve_vector_store_file_batch(vector_store_id, batch_id, **kwargs) - def cancel_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + def cancel_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return self.provider.cancel_vector_store_file_batch(vector_store_id, batch_id, **kwargs) - def poll_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + def poll_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return self.provider.poll_vector_store_file_batch(vector_store_id, batch_id, **kwargs) - def list_vector_store_file_batch_files(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: + def list_vector_store_file_batch_files( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: return self.provider.list_vector_store_file_batch_files(vector_store_id, batch_id, **kwargs) - def upload_vector_store_file_batch_and_poll(self, vector_store_id: str, files: Any, **kwargs: Any) -> Any: - return self.provider.upload_vector_store_file_batch_and_poll(vector_store_id, files, **kwargs) + def upload_vector_store_file_batch_and_poll( + self, vector_store_id: str, files: Any, **kwargs: Any + ) -> Any: + return self.provider.upload_vector_store_file_batch_and_poll( + vector_store_id, files, **kwargs + ) def list_models(self, **kwargs: Any) -> Any: return self.provider.list_models(**kwargs) @@ -552,8 +563,12 @@ def list_containers(self, **kwargs: Any) -> Any: def delete_container(self, container_id: str, **kwargs: Any) -> Any: return self.provider.delete_container(container_id, **kwargs) - def create_container_file(self, container_id: str, file: Any = None, file_id: str | None = None, **kwargs: Any) -> Any: - return self.provider.create_container_file(container_id, file=file, file_id=file_id, **kwargs) + def create_container_file( + self, container_id: str, file: Any = None, file_id: str | None = None, **kwargs: Any + ) -> Any: + return self.provider.create_container_file( + container_id, file=file, file_id=file_id, **kwargs + ) def list_container_files(self, container_id: str, **kwargs: Any) -> Any: return self.provider.list_container_files(container_id, **kwargs) @@ -681,7 +696,9 @@ async def delete_file(self, file_id: str, **kwargs: Any) -> Any: async def wait_for_file(self, file_id: str, **kwargs: Any) -> Any: return await self.provider.async_wait_for_file(file_id, **kwargs) - async def create_batch(self, input_file_id: str, endpoint: str | None = None, **kwargs: Any) -> Any: + async def create_batch( + self, input_file_id: str, endpoint: str | None = None, **kwargs: Any + ) -> Any: return await self.provider.async_create_batch(input_file_id, endpoint=endpoint, **kwargs) async def retrieve_batch(self, batch_id: str, **kwargs: Any) -> Any: @@ -732,56 +749,110 @@ async def delete_vector_store(self, vector_store_id: str, **kwargs: Any) -> Any: async def update_vector_store(self, vector_store_id: str, **kwargs: Any) -> Any: return await self.provider.async_update_vector_store(vector_store_id, **kwargs) - async def create_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: - return await self.provider.async_create_vector_store_file(vector_store_id, file_id, **kwargs) + async def create_vector_store_file( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_create_vector_store_file( + vector_store_id, file_id, **kwargs + ) - async def create_vector_store_file_and_poll(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: - return await self.provider.async_create_vector_store_file_and_poll(vector_store_id, file_id, **kwargs) + async def create_vector_store_file_and_poll( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_create_vector_store_file_and_poll( + vector_store_id, file_id, **kwargs + ) async def upload_vector_store_file(self, vector_store_id: str, file: Any, **kwargs: Any) -> Any: return await self.provider.async_upload_vector_store_file(vector_store_id, file, **kwargs) - async def upload_vector_store_file_and_poll(self, vector_store_id: str, file: Any, **kwargs: Any) -> Any: - return await self.provider.async_upload_vector_store_file_and_poll(vector_store_id, file, **kwargs) + async def upload_vector_store_file_and_poll( + self, vector_store_id: str, file: Any, **kwargs: Any + ) -> Any: + return await self.provider.async_upload_vector_store_file_and_poll( + vector_store_id, file, **kwargs + ) async def create_vector_store_file_batch(self, vector_store_id: str, **kwargs: Any) -> Any: return await self.provider.async_create_vector_store_file_batch(vector_store_id, **kwargs) - async def create_vector_store_file_batch_and_poll(self, vector_store_id: str, **kwargs: Any) -> Any: - return await self.provider.async_create_vector_store_file_batch_and_poll(vector_store_id, **kwargs) + async def create_vector_store_file_batch_and_poll( + self, vector_store_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_create_vector_store_file_batch_and_poll( + vector_store_id, **kwargs + ) - async def retrieve_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: - return await self.provider.async_retrieve_vector_store_file(vector_store_id, file_id, **kwargs) + async def retrieve_vector_store_file( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_retrieve_vector_store_file( + vector_store_id, file_id, **kwargs + ) async def list_vector_store_files(self, vector_store_id: str, **kwargs: Any) -> Any: return await self.provider.async_list_vector_store_files(vector_store_id, **kwargs) - async def update_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: - return await self.provider.async_update_vector_store_file(vector_store_id, file_id, **kwargs) + async def update_vector_store_file( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_update_vector_store_file( + vector_store_id, file_id, **kwargs + ) - async def delete_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: - return await self.provider.async_delete_vector_store_file(vector_store_id, file_id, **kwargs) + async def delete_vector_store_file( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_delete_vector_store_file( + vector_store_id, file_id, **kwargs + ) - async def vector_store_file_content(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: - return await self.provider.async_vector_store_file_content(vector_store_id, file_id, **kwargs) + async def vector_store_file_content( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_vector_store_file_content( + vector_store_id, file_id, **kwargs + ) - async def poll_vector_store_file(self, vector_store_id: str, file_id: str, **kwargs: Any) -> Any: + async def poll_vector_store_file( + self, vector_store_id: str, file_id: str, **kwargs: Any + ) -> Any: return await self.provider.async_poll_vector_store_file(vector_store_id, file_id, **kwargs) - async def retrieve_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: - return await self.provider.async_retrieve_vector_store_file_batch(vector_store_id, batch_id, **kwargs) + async def retrieve_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_retrieve_vector_store_file_batch( + vector_store_id, batch_id, **kwargs + ) - async def cancel_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: - return await self.provider.async_cancel_vector_store_file_batch(vector_store_id, batch_id, **kwargs) + async def cancel_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_cancel_vector_store_file_batch( + vector_store_id, batch_id, **kwargs + ) - async def poll_vector_store_file_batch(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: - return await self.provider.async_poll_vector_store_file_batch(vector_store_id, batch_id, **kwargs) + async def poll_vector_store_file_batch( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_poll_vector_store_file_batch( + vector_store_id, batch_id, **kwargs + ) - async def list_vector_store_file_batch_files(self, vector_store_id: str, batch_id: str, **kwargs: Any) -> Any: - return await self.provider.async_list_vector_store_file_batch_files(vector_store_id, batch_id, **kwargs) + async def list_vector_store_file_batch_files( + self, vector_store_id: str, batch_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_list_vector_store_file_batch_files( + vector_store_id, batch_id, **kwargs + ) - async def upload_vector_store_file_batch_and_poll(self, vector_store_id: str, files: Any, **kwargs: Any) -> Any: - return await self.provider.async_upload_vector_store_file_batch_and_poll(vector_store_id, files, **kwargs) + async def upload_vector_store_file_batch_and_poll( + self, vector_store_id: str, files: Any, **kwargs: Any + ) -> Any: + return await self.provider.async_upload_vector_store_file_batch_and_poll( + vector_store_id, files, **kwargs + ) async def list_models(self, **kwargs: Any) -> Any: return await self.provider.async_list_models(**kwargs) @@ -885,14 +956,24 @@ async def delete_conversation(self, conversation_id: str, **kwargs: Any) -> Any: async def list_conversation_items(self, conversation_id: str, **kwargs: Any) -> Any: return await self.provider.async_list_conversation_items(conversation_id, **kwargs) - async def create_conversation_items(self, conversation_id: str, items: Any, **kwargs: Any) -> Any: + async def create_conversation_items( + self, conversation_id: str, items: Any, **kwargs: Any + ) -> Any: return await self.provider.async_create_conversation_items(conversation_id, items, **kwargs) - async def retrieve_conversation_item(self, conversation_id: str, item_id: str, **kwargs: Any) -> Any: - return await self.provider.async_retrieve_conversation_item(conversation_id, item_id, **kwargs) + async def retrieve_conversation_item( + self, conversation_id: str, item_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_retrieve_conversation_item( + conversation_id, item_id, **kwargs + ) - async def delete_conversation_item(self, conversation_id: str, item_id: str, **kwargs: Any) -> Any: - return await self.provider.async_delete_conversation_item(conversation_id, item_id, **kwargs) + async def delete_conversation_item( + self, conversation_id: str, item_id: str, **kwargs: Any + ) -> Any: + return await self.provider.async_delete_conversation_item( + conversation_id, item_id, **kwargs + ) async def create_container(self, **kwargs: Any) -> Any: return await self.provider.async_create_container(**kwargs) @@ -906,8 +987,12 @@ async def list_containers(self, **kwargs: Any) -> Any: async def delete_container(self, container_id: str, **kwargs: Any) -> Any: return await self.provider.async_delete_container(container_id, **kwargs) - async def create_container_file(self, container_id: str, file: Any = None, file_id: str | None = None, **kwargs: Any) -> Any: - return await self.provider.async_create_container_file(container_id, file=file, file_id=file_id, **kwargs) + async def create_container_file( + self, container_id: str, file: Any = None, file_id: str | None = None, **kwargs: Any + ) -> Any: + return await self.provider.async_create_container_file( + container_id, file=file, file_id=file_id, **kwargs + ) async def list_container_files(self, container_id: str, **kwargs: Any) -> Any: return await self.provider.async_list_container_files(container_id, **kwargs) @@ -984,7 +1069,6 @@ async def retrieve_eval_run_output_item( class GoogleResources(_NativeFacade): - _delegate_native = True @property @@ -1068,9 +1152,7 @@ def update_model(self, model: str, config: Any = None, **kwargs: Any) -> Any: def tune( self, base_model: str, training_dataset: Any, config: Any = None, **kwargs: Any ) -> Any: - return self.provider.tune( - base_model, training_dataset, config=config, **kwargs - ) + return self.provider.tune(base_model, training_dataset, config=config, **kwargs) def get_tuning(self, name: str, **kwargs: Any) -> Any: return self.provider.get_tuning(name, **kwargs) @@ -1230,10 +1312,16 @@ def list_file_search_stores(self, **kwargs: Any) -> Any: def delete_file_search_store(self, name: str, **kwargs: Any) -> Any: return self.provider.delete_file_search_store(name, **kwargs) - def import_file_to_file_search_store(self, file_search_store_name: str, file_name: str, **kwargs: Any) -> Any: - return self.provider.import_file_to_file_search_store(file_search_store_name, file_name, **kwargs) + def import_file_to_file_search_store( + self, file_search_store_name: str, file_name: str, **kwargs: Any + ) -> Any: + return self.provider.import_file_to_file_search_store( + file_search_store_name, file_name, **kwargs + ) - def upload_to_file_search_store(self, file_search_store_name: str, file: Any, **kwargs: Any) -> Any: + def upload_to_file_search_store( + self, file_search_store_name: str, file: Any, **kwargs: Any + ) -> Any: return self.provider.upload_to_file_search_store(file_search_store_name, file, **kwargs) def download_file_search_media(self, media_id: str, **kwargs: Any) -> bytes: @@ -1408,9 +1496,7 @@ async def update_model(self, model: str, config: Any = None, **kwargs: Any) -> A async def tune( self, base_model: str, training_dataset: Any, config: Any = None, **kwargs: Any ) -> Any: - return await self.provider.async_tune( - base_model, training_dataset, config=config, **kwargs - ) + return await self.provider.async_tune(base_model, training_dataset, config=config, **kwargs) async def get_tuning(self, name: str, **kwargs: Any) -> Any: return await self.provider.async_get_tuning(name, **kwargs) @@ -1490,12 +1576,16 @@ async def list_file_search_stores(self, **kwargs: Any) -> Any: async def delete_file_search_store(self, name: str, **kwargs: Any) -> Any: return await self.provider.async_delete_file_search_store(name, **kwargs) - async def import_file_to_file_search_store(self, file_search_store_name: str, file_name: str, **kwargs: Any) -> Any: + async def import_file_to_file_search_store( + self, file_search_store_name: str, file_name: str, **kwargs: Any + ) -> Any: return await self.provider.async_import_file_to_file_search_store( file_search_store_name, file_name, **kwargs ) - async def upload_to_file_search_store(self, file_search_store_name: str, file: Any, **kwargs: Any) -> Any: + async def upload_to_file_search_store( + self, file_search_store_name: str, file: Any, **kwargs: Any + ) -> Any: return await self.provider.async_upload_to_file_search_store( file_search_store_name, file, **kwargs ) @@ -1505,7 +1595,6 @@ async def download_file_search_media(self, media_id: str, **kwargs: Any) -> byte class AnthropicResources(_NativeFacade): - _delegate_native = True def _beta_resource(self, name: str) -> Any: @@ -1757,7 +1846,6 @@ def beta_tool_runner(self, tools: Any, request: Any = None, **kwargs: Any) -> An class ArkResources(_NativeFacade): - _delegate_native = True def upload_file(self, file: Any, purpose: str, **kwargs: Any) -> Any: diff --git a/src/providers/siliconflow.py b/src/providers/siliconflow.py index 67e3d58..935ed4a 100644 --- a/src/providers/siliconflow.py +++ b/src/providers/siliconflow.py @@ -9,5 +9,12 @@ class SiliconflowProvider(OpenAICompatibleProvider): 通过继承OpenAICompatibleProvider来复用与OpenAI API兼容的逻辑。 """ - def __init__(self, model_name: str, protocol: str = "chat_completions", options: dict[str, Any] | None = None): - super().__init__(model_name=model_name, provider="siliconflow", protocol=protocol, options=options) + def __init__( + self, + model_name: str, + protocol: str = "chat_completions", + options: dict[str, Any] | None = None, + ): + super().__init__( + model_name=model_name, provider="siliconflow", protocol=protocol, options=options + ) diff --git a/src/providers/siliconflow_rerank.py b/src/providers/siliconflow_rerank.py index 3c457b8..7e0056d 100644 --- a/src/providers/siliconflow_rerank.py +++ b/src/providers/siliconflow_rerank.py @@ -16,6 +16,7 @@ logger = get_module_logger(__name__) + class SiliconflowRerankProvider(RerankModel): """ SiliconFlow Rerank模型提供商。 @@ -33,14 +34,14 @@ def __init__(self, model_name: str, options: dict[str, Any] | None = None): settings = get_settings() self._api_key = settings.siliconflow_api_key self._base_url = settings.siliconflow_base_url - + if not self._api_key: logger.error("SiliconFlow API Key 未设置。") raise ValueError("SILICONFLOW_API_KEY is required for SiliconflowRerankProvider") if not self._base_url: logger.error("SiliconFlow Base URL 未设置。") raise ValueError("SILICONFLOW_BASE_URL is required for SiliconflowRerankProvider") - + logger.info(f"初始化 SiliconflowRerankProvider,模型: {model_name}") def _get_headers(self) -> dict[str, str]: @@ -53,7 +54,7 @@ def _prepare_payload(self, query: str, documents: list[str], top_n: int) -> dict "documents": documents, "model": self._model_name, "top_n": top_n, - "return_documents": True + "return_documents": True, } options = dict(self._options) options.pop("timeout", None) @@ -64,9 +65,7 @@ def _prepare_payload(self, query: str, documents: list[str], top_n: int) -> dict ) overlap = sorted(set(payload).intersection(options)) if overlap: - raise ValueError( - "SiliconFlow Rerank options 不允许覆盖请求字段: " + ", ".join(overlap) - ) + raise ValueError("SiliconFlow Rerank options 不允许覆盖请求字段: " + ", ".join(overlap)) if extra_body: option_overlap = sorted(set(options).intersection(extra_body)) if option_overlap: @@ -106,7 +105,9 @@ def _validate_inputs(query: str, documents: list[str], top_n: int) -> None: if not isinstance(top_n, int) or isinstance(top_n, bool) or top_n < 1: raise ValueError("SiliconFlow Rerank top_n 必须是大于等于 1 的整数。") - def _parse_response(self, results: list[dict], documents: list[str]) -> tuple[list[int], list[float]]: + def _parse_response( + self, results: list[dict], documents: list[str] + ) -> tuple[list[int], list[float]]: positions: dict[str, list[int]] = {} for index, content in enumerate(documents): positions.setdefault(content, []).append(index) @@ -129,17 +130,13 @@ def _parse_response(self, results: list[dict], documents: list[str]) -> tuple[li raise RuntimeError("SiliconFlow Rerank 响应包含重复 index。") index = direct_index document = res.get("document") - document_text = ( - document.get("text") if isinstance(document, Mapping) else document - ) + document_text = document.get("text") if isinstance(document, Mapping) else document if document_text is not None and document_text != documents[index]: raise RuntimeError("SiliconFlow Rerank 响应 index 与 document 不匹配。") positions.get(documents[index], []).remove(index) else: document = res.get("document") - doc_content = ( - document.get("text") if isinstance(document, Mapping) else document - ) + doc_content = document.get("text") if isinstance(document, Mapping) else document if not isinstance(doc_content, str) or not positions.get(doc_content): raise RuntimeError("SiliconFlow Rerank 响应无法映射到输入文档。") index = positions[doc_content].pop(0) @@ -157,21 +154,22 @@ def _parse_response(self, results: list[dict], documents: list[str]) -> tuple[li stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception(is_retryable_error), - reraise=True + reraise=True, ) def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int], list[float]]: """同步 Rerank (CSE Sensor)。""" logger.info(f"调用 SiliconFlow Rerank ({self._model_name}),文档数: {len(documents)}") import httpx + start_time = time.perf_counter() url = f"{self._base_url.rstrip('/')}/rerank" - + try: with httpx.Client(timeout=self._request_timeout()) as client: response = client.post( url, headers=self._get_headers(), - json=self._prepare_payload(query, documents, top_n) + json=self._prepare_payload(query, documents, top_n), ) response.raise_for_status() payload = response.json() @@ -180,7 +178,7 @@ def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int results = payload.get("results") if not isinstance(results, list): raise RuntimeError("SiliconFlow Rerank 响应缺少有效的 results 列表。") - + indices, scores = self._parse_response(results, documents) duration = time.perf_counter() - start_time logger.info(f"SiliconFlow Rerank ({self._model_name}) 完成,耗时: {duration:.2f}s") @@ -198,21 +196,24 @@ def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10), retry=retry_if_exception(is_retryable_error), - reraise=True + reraise=True, ) - async def arerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int], list[float]]: + async def arerank( + self, query: str, documents: list[str], top_n: int + ) -> tuple[list[int], list[float]]: """异步 Rerank (CSE Sensor)。""" logger.info(f"异步调用 SiliconFlow Rerank ({self._model_name}),文档数: {len(documents)}") import httpx + start_time = time.perf_counter() url = f"{self._base_url.rstrip('/')}/rerank" - + try: async with httpx.AsyncClient(timeout=self._request_timeout()) as aclient: response = await aclient.post( url, headers=self._get_headers(), - json=self._prepare_payload(query, documents, top_n) + json=self._prepare_payload(query, documents, top_n), ) response.raise_for_status() payload = response.json() @@ -221,7 +222,7 @@ async def arerank(self, query: str, documents: list[str], top_n: int) -> tuple[l results = payload.get("results") if not isinstance(results, list): raise RuntimeError("SiliconFlow Rerank 响应缺少有效的 results 列表。") - + indices, scores = self._parse_response(results, documents) duration = time.perf_counter() - start_time logger.info(f"SiliconFlow Rerank ({self._model_name}) 异步完成,耗时: {duration:.2f}s") diff --git a/src/providers/volcengine.py b/src/providers/volcengine.py index 05702a7..0af6291 100644 --- a/src/providers/volcengine.py +++ b/src/providers/volcengine.py @@ -56,7 +56,6 @@ def _load_ark_clients() -> tuple[type[Any], type[Any]]: return Ark, AsyncArk - def _ark_responses_output_text(response: Any) -> str: """从 Responses 的 ``output[].content[]`` 提取助手正文。 @@ -81,33 +80,89 @@ class VolcengineProvider(LargeLanguageModel, TextEmbeddingModel): _ARK_CHAT_MODEL_OPTION_KEYS = frozenset( { - "frequency_penalty", "function_call", "logit_bias", "logprobs", - "max_completion_tokens", "max_tokens", "n", "parallel_tool_calls", - "presence_penalty", "reasoning_effort", "repetition_penalty", - "response_format", "service_tier", "stop", "stream_options", - "temperature", "thinking", "tool_choice", "top_logprobs", "top_p", - "user", "extra_body", "top_k", "seed", + "frequency_penalty", + "function_call", + "logit_bias", + "logprobs", + "max_completion_tokens", + "max_tokens", + "n", + "parallel_tool_calls", + "presence_penalty", + "reasoning_effort", + "repetition_penalty", + "response_format", + "service_tier", + "stop", + "stream_options", + "temperature", + "thinking", + "tool_choice", + "top_logprobs", + "top_p", + "user", + "extra_body", + "top_k", + "seed", } ) _ARK_RESPONSES_MODEL_OPTION_KEYS = frozenset( { - "caching", "context_management", "conversation", "expire_at", - "extra_body", "frequency_penalty", "max_completion_tokens", - "max_output_tokens", "max_tokens", "max_tool_calls", - "parallel_tool_calls", "previous_response_id", "presence_penalty", - "reasoning", "reasoning_effort", "response_format", "service_tier", - "session", "store", "temperature", "text", "thinking", "tool_choice", - "top_k", "top_p", "seed", + "caching", + "context_management", + "conversation", + "expire_at", + "extra_body", + "frequency_penalty", + "max_completion_tokens", + "max_output_tokens", + "max_tokens", + "max_tool_calls", + "parallel_tool_calls", + "previous_response_id", + "presence_penalty", + "reasoning", + "reasoning_effort", + "response_format", + "service_tier", + "session", + "store", + "temperature", + "text", + "thinking", + "tool_choice", + "top_k", + "top_p", + "seed", } ) _ARK_RESPONSES_CREATE_KEYS = frozenset( { - "input", "model", "instructions", "max_output_tokens", - "parallel_tool_calls", "previous_response_id", "thinking", "store", - "caching", "stream", "temperature", "text", "tool_choice", "tools", - "top_p", "max_tool_calls", "context_management", "expire_at", - "extra_headers", "extra_query", "extra_body", "timeout", "reasoning", - "session", "service_tier", + "input", + "model", + "instructions", + "max_output_tokens", + "parallel_tool_calls", + "previous_response_id", + "thinking", + "store", + "caching", + "stream", + "temperature", + "text", + "tool_choice", + "tools", + "top_p", + "max_tool_calls", + "context_management", + "expire_at", + "extra_headers", + "extra_query", + "extra_body", + "timeout", + "reasoning", + "session", + "service_tier", } ) _ARK_RESPONSES_RETRIEVE_KEYS = frozenset( @@ -115,120 +170,304 @@ class VolcengineProvider(LargeLanguageModel, TextEmbeddingModel): ) _ARK_RESPONSES_INPUT_ITEMS_KEYS = frozenset( { - "after", "before", "include", "limit", "order", - "extra_headers", "extra_query", "extra_body", "timeout", + "after", + "before", + "include", + "limit", + "order", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_BATCH_MULTIMODAL_EMBEDDING_KEYS = frozenset( { - "input", "model", "encoding_format", "dimensions", "instructions", - "extra_headers", "extra_query", "extra_body", "timeout", + "input", + "model", + "encoding_format", + "dimensions", + "instructions", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_BATCH_CHAT_KEYS = frozenset( { - "messages", "model", "frequency_penalty", "function_call", - "logit_bias", "logprobs", "max_tokens", "presence_penalty", - "stop", "temperature", "tools", "top_logprobs", "top_p", - "repetition_penalty", "n", "parallel_tool_calls", "service_tier", - "tool_choice", "response_format", "thinking", "max_completion_tokens", - "user", "extra_headers", "extra_query", "extra_body", "timeout", + "messages", + "model", + "frequency_penalty", + "function_call", + "logit_bias", + "logprobs", + "max_tokens", + "presence_penalty", + "stop", + "temperature", + "tools", + "top_logprobs", + "top_p", + "repetition_penalty", + "n", + "parallel_tool_calls", + "service_tier", + "tool_choice", + "response_format", + "thinking", + "max_completion_tokens", + "user", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_BATCH_EMBEDDING_KEYS = frozenset( { - "input", "model", "encoding_format", "user", "extra_headers", - "extra_query", "extra_body", "timeout", + "input", + "model", + "encoding_format", + "user", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_BATCH_CHAT_ASYNC_KEYS = _ARK_BATCH_CHAT_KEYS _ARK_CONTEXT_CREATE_KEYS = frozenset( { - "model", "messages", "ttl", "mode", "truncation_strategy", - "extra_headers", "extra_query", "extra_body", "timeout", + "model", + "messages", + "ttl", + "mode", + "truncation_strategy", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_CONTEXT_COMPLETION_KEYS = frozenset( { - "context_id", "messages", "model", "frequency_penalty", - "function_call", "logit_bias", "logprobs", "max_tokens", - "presence_penalty", "stop", "stream", "stream_options", - "temperature", "tools", "top_logprobs", "top_p", - "repetition_penalty", "n", "tool_choice", "response_format", - "user", "extra_headers", "extra_query", "extra_body", "timeout", + "context_id", + "messages", + "model", + "frequency_penalty", + "function_call", + "logit_bias", + "logprobs", + "max_tokens", + "presence_penalty", + "stop", + "stream", + "stream_options", + "temperature", + "tools", + "top_logprobs", + "top_p", + "repetition_penalty", + "n", + "tool_choice", + "response_format", + "user", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_CLASSIFICATION_KEYS = frozenset( - {"query", "model", "labels", "user", "extra_headers", "extra_query", "extra_body", "timeout"} + { + "query", + "model", + "labels", + "user", + "extra_headers", + "extra_query", + "extra_body", + "timeout", + } ) _ARK_CONTENT_GENERATION_CREATE_KEYS = frozenset( { - "model", "content", "safety_identifier", "callback_url", - "return_last_frame", "service_tier", "execution_expires_after", - "priority", "generate_audio", "draft", "camera_fixed", "watermark", - "seed", "resolution", "ratio", "duration", "frames", "tools", - "output_format", "omni_reference_task_type", "extra_headers", - "extra_query", "extra_body", "timeout", + "model", + "content", + "safety_identifier", + "callback_url", + "return_last_frame", + "service_tier", + "execution_expires_after", + "priority", + "generate_audio", + "draft", + "camera_fixed", + "watermark", + "seed", + "resolution", + "ratio", + "duration", + "frames", + "tools", + "output_format", + "omni_reference_task_type", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_CONTENT_GENERATION_LIST_KEYS = frozenset( { - "page_num", "page_size", "status", "task_ids", "model", "service_tier", - "extra_headers", "extra_query", "extra_body", "timeout", + "page_num", + "page_size", + "status", + "task_ids", + "model", + "service_tier", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_FILE_CREATE_KEYS = frozenset( { - "expire_at", "preprocess_configs", "url", "tos", "extra_headers", - "extra_query", "extra_body", "timeout", + "expire_at", + "preprocess_configs", + "url", + "tos", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_FILE_LIST_KEYS = frozenset( { - "after", "limit", "order", "purpose", "extra_headers", "extra_query", - "extra_body", "timeout", + "after", + "limit", + "order", + "purpose", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_IMAGE_GENERATE_KEYS = frozenset( { - "model", "prompt", "image", "response_format", "size", "seed", - "guidance_scale", "watermark", "optimize_prompt", - "optimize_prompt_options", "extra_headers", "extra_query", "extra_body", - "timeout", "sequential_image_generation", - "sequential_image_generation_options", "tools", "output_format", - "layer_decomposition", "stream", + "model", + "prompt", + "image", + "response_format", + "size", + "seed", + "guidance_scale", + "watermark", + "optimize_prompt", + "optimize_prompt_options", + "extra_headers", + "extra_query", + "extra_body", + "timeout", + "sequential_image_generation", + "sequential_image_generation_options", + "tools", + "output_format", + "layer_decomposition", + "stream", } ) _ARK_BETA_CHAT_KEYS = frozenset( { - "messages", "model", "response_format", "frequency_penalty", "logit_bias", - "logprobs", "max_tokens", "n", "parallel_tool_calls", "presence_penalty", - "service_tier", "stop", "stream_options", "temperature", "tool_choice", - "tools", "top_logprobs", "top_p", "user", "reasoning_effort", - "extra_headers", "extra_query", "extra_body", "timeout", + "messages", + "model", + "response_format", + "frequency_penalty", + "logit_bias", + "logprobs", + "max_tokens", + "n", + "parallel_tool_calls", + "presence_penalty", + "service_tier", + "stop", + "stream_options", + "temperature", + "tool_choice", + "tools", + "top_logprobs", + "top_p", + "user", + "reasoning_effort", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) _ARK_BOT_CHAT_KEYS = frozenset( { - "messages", "model", "frequency_penalty", "function_call", "logit_bias", - "logprobs", "max_tokens", "presence_penalty", "stop", "stream", - "stream_options", "temperature", "tools", "top_logprobs", "top_p", - "repetition_penalty", "n", "parallel_tool_calls", "service_tier", - "tool_choice", "response_format", "user", "metadata", "extra_headers", - "extra_query", "extra_body", "timeout", + "messages", + "model", + "frequency_penalty", + "function_call", + "logit_bias", + "logprobs", + "max_tokens", + "presence_penalty", + "stop", + "stream", + "stream_options", + "temperature", + "tools", + "top_logprobs", + "top_p", + "repetition_penalty", + "n", + "parallel_tool_calls", + "service_tier", + "tool_choice", + "response_format", + "user", + "metadata", + "extra_headers", + "extra_query", + "extra_body", + "timeout", } ) capabilities = frozenset( { - "chat", "stream", "messages", "multimodal", "tools", "structured_output", - "usage", "embedding", "responses", "files", "batches", "token_count", - "images", "multimodal_embedding", "context", "content_generation", - "beta_chat", "bot_chat", "classification", + "chat", + "stream", + "messages", + "multimodal", + "tools", + "structured_output", + "usage", + "embedding", + "responses", + "files", + "batches", + "token_count", + "images", + "multimodal_embedding", + "context", + "content_generation", + "beta_chat", + "bot_chat", + "classification", } ) - def __init__(self, model_name: str, protocol: str = "ark", options: dict[str, Any] | None = None): + def __init__( + self, model_name: str, protocol: str = "ark", options: dict[str, Any] | None = None + ): normalized = str(protocol).strip().lower().replace("-", "_") if normalized in {"ark", "chat", "chat_completion", "chat_completions"}: self._protocol = "chat_completions" @@ -271,9 +510,7 @@ def async_resources(self) -> AsyncArkResources: return AsyncArkResources(self) def _client_options(self) -> dict[str, Any]: - options = self._normalize_client_options( - getattr(self, "_options", {}) or {} - ) + options = self._normalize_client_options(getattr(self, "_options", {}) or {}) result: dict[str, Any] = {"base_url": self._base_url} if self._base_url else {} if self._api_key and self._secret_key: result.update({"ak": self._api_key, "sk": self._secret_key}) @@ -325,9 +562,7 @@ def _normalize_client_options(options: Mapping[str, Any]) -> dict[str, Any]: "Volcengine options.max_retries 必须是大于等于 0 的整数。" ) from exc if text not in {str(parsed), f"+{parsed}"}: - raise ValueError( - "Volcengine options.max_retries 必须是大于等于 0 的整数。" - ) + raise ValueError("Volcengine options.max_retries 必须是大于等于 0 的整数。") value = parsed elif isinstance(value, float) and math.isfinite(value) and value.is_integer(): value = int(value) @@ -361,9 +596,7 @@ def _normalize_verified_protocols(cls, configured: Any) -> frozenset[str]: normalized: set[str] = set() for value in configured: if not isinstance(value, str) or not value.strip(): - raise ValueError( - "server_verified_protocols 中的协议必须是非空字符串。" - ) + raise ValueError("server_verified_protocols 中的协议必须是非空字符串。") protocol = value.strip().lower().replace("-", "_") protocol = aliases.get(protocol, protocol) if protocol not in supported: @@ -445,8 +678,11 @@ def _request_options(self) -> dict[str, Any]: return { key: value for key, value in self._options.items() - if key not in { - "timeout", "max_retries", "region", + if key + not in { + "timeout", + "max_retries", + "region", "server_verified_protocols", } } @@ -464,9 +700,7 @@ def _validated_extra_body( value = validate_secret_free_payload(value, endpoint, "extra_body") overlap = sorted(set(value).intersection(reserved)) if overlap: - raise ValueError( - f"{endpoint} extra_body 不允许覆盖请求字段: {', '.join(overlap)}" - ) + raise ValueError(f"{endpoint} extra_body 不允许覆盖请求字段: {', '.join(overlap)}") return dict(value) @staticmethod @@ -480,9 +714,7 @@ def _merge_extra_body( extension_values = dict(extensions or {}) overlap = sorted(set(configured_values).intersection(extension_values)) if overlap: - raise ValueError( - f"{endpoint} 模型 options 的扩展字段重复: {', '.join(overlap)}" - ) + raise ValueError(f"{endpoint} 模型 options 的扩展字段重复: {', '.join(overlap)}") return {**configured_values, **extension_values} @staticmethod @@ -520,36 +752,24 @@ def _convert_responses_tools(tools: list[dict[str, Any]] | None) -> list[dict[st native_tool = dict(tool) tool_type = native_tool.get("type") if not isinstance(tool_type, str) or not tool_type.strip(): - raise ValueError( - f"Ark Responses 工具定义[{index}] 缺少有效 type。" - ) + raise ValueError(f"Ark Responses 工具定义[{index}] 缺少有效 type。") if tool_type == "function": name = native_tool.get("name") if not isinstance(name, str) or not name.strip(): - raise ValueError( - f"Ark Responses 工具定义[{index}] 缺少 name。" - ) - parameters = native_tool.get( - "parameters", {"type": "object", "properties": {}} - ) + raise ValueError(f"Ark Responses 工具定义[{index}] 缺少 name。") + parameters = native_tool.get("parameters", {"type": "object", "properties": {}}) if not isinstance(parameters, Mapping): - raise ValueError( - f"Ark Responses 工具定义[{index}].parameters 必须是对象。" - ) + raise ValueError(f"Ark Responses 工具定义[{index}].parameters 必须是对象。") native_tool["parameters"] = dict(parameters) converted.append(native_tool) continue function = tool["function"] if not isinstance(function, Mapping): - raise ValueError( - f"Ark Responses 工具定义[{index}].function 必须是对象。" - ) + raise ValueError(f"Ark Responses 工具定义[{index}].function 必须是对象。") name = function.get("name") if not isinstance(name, str) or not name.strip(): raise ValueError("Ark Responses 工具定义缺少 function.name。") - parameters = function.get( - "parameters", {"type": "object", "properties": {}} - ) + parameters = function.get("parameters", {"type": "object", "properties": {}}) if not isinstance(parameters, Mapping): raise ValueError( f"Ark Responses 工具定义[{index}].function.parameters 必须是对象。" @@ -586,27 +806,45 @@ def _convert_responses_tool_choice(tool_choice: Any) -> Any: if not name: raise ValueError("Ark Responses 的 function tool_choice 缺少 name。") return {"type": "function", "name": name} - if tool_choice.get("type") in { - "mcp", "web_search", "knowledge_search" - }: + if tool_choice.get("type") in {"mcp", "web_search", "knowledge_search"}: return dict(tool_choice) raise ValueError("Ark Responses 的 tool_choice 必须是 auto、none、required 或工具对象。") - def _build_chat_request(self, prompt: str | None = None, system_prompt: str | None = None, - tools: list[dict[str, Any]] | None = None, temperature: float | None = None, - stream: bool = True, *, messages: Any = None, max_tokens: int | None = None, - top_p: float | None = None, top_k: int | None = None, seed: int | None = None, - stop: str | list[str] | None = None, - response_format: dict[str, Any] | None = None, tool_choice: Any = None, - extra_body: dict[str, Any] | None = None, user: str | None = None, - extra_headers: dict[str, str] | None = None, extra_query: dict[str, Any] | None = None, - frequency_penalty: float | None = None, presence_penalty: float | None = None, - repetition_penalty: float | None = None, n: int | None = None, - logit_bias: dict[str, int] | None = None, logprobs: bool | None = None, - top_logprobs: int | None = None, parallel_tool_calls: bool | None = None, - service_tier: str | None = None, thinking: dict[str, Any] | None = None, - reasoning: dict[str, Any] | None = None, stream_options: dict[str, Any] | None = None, - timeout: float | None = None, **ignored: Any) -> dict[str, Any]: + def _build_chat_request( + self, + prompt: str | None = None, + system_prompt: str | None = None, + tools: list[dict[str, Any]] | None = None, + temperature: float | None = None, + stream: bool = True, + *, + messages: Any = None, + max_tokens: int | None = None, + top_p: float | None = None, + top_k: int | None = None, + seed: int | None = None, + stop: str | list[str] | None = None, + response_format: dict[str, Any] | None = None, + tool_choice: Any = None, + extra_body: dict[str, Any] | None = None, + user: str | None = None, + extra_headers: dict[str, str] | None = None, + extra_query: dict[str, Any] | None = None, + frequency_penalty: float | None = None, + presence_penalty: float | None = None, + repetition_penalty: float | None = None, + n: int | None = None, + logit_bias: dict[str, int] | None = None, + logprobs: bool | None = None, + top_logprobs: int | None = None, + parallel_tool_calls: bool | None = None, + service_tier: str | None = None, + thinking: dict[str, Any] | None = None, + reasoning: dict[str, Any] | None = None, + stream_options: dict[str, Any] | None = None, + timeout: float | None = None, + **ignored: Any, + ) -> dict[str, Any]: reject_unsupported_kwargs("Ark Chat Completions", ignored) extra_headers, extra_query = validate_secret_free_request_overrides( extra_headers, @@ -639,24 +877,19 @@ def _build_chat_request(self, prompt: str | None = None, system_prompt: str | No configured_max_completion_tokens = configured_options.pop("max_completion_tokens", None) if configured_max_tokens is not None and configured_max_completion_tokens is not None: raise ValueError( - "Ark Chat Completions options 不能同时设置 max_tokens 和 " - "max_completion_tokens。" + "Ark Chat Completions options 不能同时设置 max_tokens 和 max_completion_tokens。" ) configured_top_k = configured_options.pop("top_k", None) configured_seed = configured_options.pop("seed", None) configured_scalar_extra_keys: set[str] = set() if configured_top_k is not None: if "top_k" in configured_extra_body: - raise ValueError( - "Ark Chat Completions 模型 options 的 top_k 与 extra_body 重复。" - ) + raise ValueError("Ark Chat Completions 模型 options 的 top_k 与 extra_body 重复。") configured_extra_body["top_k"] = configured_top_k configured_scalar_extra_keys.add("top_k") if configured_seed is not None: if "seed" in configured_extra_body: - raise ValueError( - "Ark Chat Completions 模型 options 的 seed 与 extra_body 重复。" - ) + raise ValueError("Ark Chat Completions 模型 options 的 seed 与 extra_body 重复。") configured_extra_body["seed"] = configured_seed configured_scalar_extra_keys.add("seed") self._merge_options(request, configured_options) @@ -688,7 +921,9 @@ def _build_chat_request(self, prompt: str | None = None, system_prompt: str | No "parallel_tool_calls": parallel_tool_calls, "service_tier": service_tier, "thinking": thinking, - "reasoning_effort": (reasoning or {}).get("effort") if isinstance(reasoning, dict) else None, + "reasoning_effort": (reasoning or {}).get("effort") + if isinstance(reasoning, dict) + else None, "stream_options": stream_options, "timeout": timeout, "extra_headers": extra_headers, @@ -698,15 +933,11 @@ def _build_chat_request(self, prompt: str | None = None, system_prompt: str | No request[key] = value if top_k is not None: if "top_k" in configured_extra_body and "top_k" not in configured_scalar_extra_keys: - raise ValueError( - "Ark Chat Completions top_k 与模型 options.extra_body 重复。" - ) + raise ValueError("Ark Chat Completions top_k 与模型 options.extra_body 重复。") request.setdefault("extra_body", {})["top_k"] = top_k if seed is not None: if "seed" in configured_extra_body and "seed" not in configured_scalar_extra_keys: - raise ValueError( - "Ark Chat Completions seed 与模型 options.extra_body 重复。" - ) + raise ValueError("Ark Chat Completions seed 与模型 options.extra_body 重复。") request.setdefault("extra_body", {})["seed"] = seed if stop is not None: request["stop"] = stop @@ -746,13 +977,38 @@ def _build_responses_request(self, request: CompletionRequest) -> dict[str, Any] "Ark Responses", ) unsupported_fields = ( - "repetition_penalty", "n", "logit_bias", "logprobs", "modalities", "audio", - "prediction", "web_search_options", "stop", "include", "background", - "metadata", "user", "moderation", "prompt_cache_options", "top_logprobs", - "safety_identifier", "prompt_cache_key", "prompt_cache_retention", - "truncation", "stream_options", "cache_control", "container", - "inference_geo", "mcp_servers", "output_config", "output_format", - "speed", "betas", "diagnostics", "fallback_credit_token", "fallbacks", + "repetition_penalty", + "n", + "logit_bias", + "logprobs", + "modalities", + "audio", + "prediction", + "web_search_options", + "stop", + "include", + "background", + "metadata", + "user", + "moderation", + "prompt_cache_options", + "top_logprobs", + "safety_identifier", + "prompt_cache_key", + "prompt_cache_retention", + "truncation", + "stream_options", + "cache_control", + "container", + "inference_geo", + "mcp_servers", + "output_config", + "output_format", + "speed", + "betas", + "diagnostics", + "fallback_credit_token", + "fallbacks", ) reject_unsupported_kwargs( "Ark Responses", @@ -811,22 +1067,16 @@ def _build_responses_request(self, request: CompletionRequest) -> dict[str, Any] configured_conversation = configured_options.pop("conversation", None) configured_session = configured_options.pop("session", None) if configured_conversation is not None and configured_session is not None: - raise ValueError( - "Ark Responses 模型 options 不能同时设置 conversation 和 session。" - ) + raise ValueError("Ark Responses 模型 options 不能同时设置 conversation 和 session。") configured_scalar_extra_keys: set[str] = set() if configured_top_k is not None: if "top_k" in configured_extra_body: - raise ValueError( - "Ark Responses 模型 options 的 top_k 与 extra_body 重复。" - ) + raise ValueError("Ark Responses 模型 options 的 top_k 与 extra_body 重复。") configured_extra_body["top_k"] = configured_top_k configured_scalar_extra_keys.add("top_k") if configured_seed is not None: if "seed" in configured_extra_body: - raise ValueError( - "Ark Responses 模型 options 的 seed 与 extra_body 重复。" - ) + raise ValueError("Ark Responses 模型 options 的 seed 与 extra_body 重复。") configured_extra_body["seed"] = configured_seed configured_scalar_extra_keys.add("seed") if request.system_prompt and request.messages is None: @@ -860,15 +1110,11 @@ def _build_responses_request(self, request: CompletionRequest) -> dict[str, Any] reject_unsupported_kwargs("Ark Responses", {"top_logprobs": request.top_logprobs}) if request.top_k is not None: if "top_k" in configured_extra_body and "top_k" not in configured_scalar_extra_keys: - raise ValueError( - "Ark Responses top_k 与模型 options.extra_body 重复。" - ) + raise ValueError("Ark Responses top_k 与模型 options.extra_body 重复。") configured_extra_body["top_k"] = request.top_k if request.seed is not None: if "seed" in configured_extra_body and "seed" not in configured_scalar_extra_keys: - raise ValueError( - "Ark Responses seed 与模型 options.extra_body 重复。" - ) + raise ValueError("Ark Responses seed 与模型 options.extra_body 重复。") configured_extra_body["seed"] = request.seed converted_tools = self._convert_responses_tools(request.tools) if converted_tools: @@ -879,19 +1125,26 @@ def _build_responses_request(self, request: CompletionRequest) -> dict[str, Any] if request.tool_choice is not None: params["tool_choice"] = self._convert_responses_tool_choice(request.tool_choice) elif configured_tool_choice is not None: - params["tool_choice"] = self._convert_responses_tool_choice( - configured_tool_choice - ) - for key in ("previous_response_id", "reasoning", "thinking", "store", "caching", "parallel_tool_calls", "max_tool_calls", "context_management", "expire_at", "service_tier"): + params["tool_choice"] = self._convert_responses_tool_choice(configured_tool_choice) + for key in ( + "previous_response_id", + "reasoning", + "thinking", + "store", + "caching", + "parallel_tool_calls", + "max_tool_calls", + "context_management", + "expire_at", + "service_tier", + ): value = getattr(request, key, None) if value is not None: params[key] = value if request.reasoning is None and configured_reasoning_effort is not None: params["reasoning"] = {"effort": configured_reasoning_effort} if request.conversation is not None and request.session is not None: - raise ValueError( - "Ark Responses 不能同时传 conversation 和 session。" - ) + raise ValueError("Ark Responses 不能同时传 conversation 和 session。") # Ark calls the response conversation state a session. Keep # `conversation` as the portable request field for OpenAI-compatible # channels while mapping it to the SDK's actual parameter here. @@ -933,8 +1186,7 @@ def _build_responses_request(self, request: CompletionRequest) -> dict[str, Any] ) if configured_overlap: raise ValueError( - "Ark Responses extra_body 与模型 options 重复: " - + ", ".join(configured_overlap) + "Ark Responses extra_body 与模型 options 重复: " + ", ".join(configured_overlap) ) params.setdefault("extra_body", {}).update(request_extra_body) if params.get("extra_body"): @@ -967,11 +1219,11 @@ def _reject_instructions_with_enabled_caching(params: Mapping[str, Any]) -> None for candidate in candidates: if isinstance(candidate, Mapping) and candidate.get("type") == "enabled": raise ValueError( - "Ark Responses 的 instructions 与 caching={\"type\": \"enabled\"} 互斥:" + 'Ark Responses 的 instructions 与 caching={"type": "enabled"} 互斥:' "官方规定配置 instructions 后本轮请求无法写入或使用缓存,caching 为 " "enabled 时请求会直接报错。instructions 来自 system_prompt," "注意 CompletionRequest.system_prompt 有兼容默认值" - "(未显式传入时为 \"You are a helpful assistant.\");" + '(未显式传入时为 "You are a helpful assistant.");' "请显式传入 system_prompt=None 并改用 messages 携带系统提示," "或移除 caching。" ) @@ -981,12 +1233,14 @@ def _validate_native_response_kwargs(cls, kwargs: Mapping[str, Any]) -> dict[str """校验 Ark SDK 原生 Responses 参数,不改变其必填字段语义。""" params = dict(kwargs) unsupported = { - key: value - for key, value in params.items() - if key not in cls._ARK_RESPONSES_CREATE_KEYS + key: value for key, value in params.items() if key not in cls._ARK_RESPONSES_CREATE_KEYS } reject_unsupported_kwargs("Ark Responses", unsupported) - if "model" not in params or not isinstance(params.get("model"), str) or not params["model"].strip(): + if ( + "model" not in params + or not isinstance(params.get("model"), str) + or not params["model"].strip() + ): raise ValueError("Ark Responses 原生调用必须显式提供 model。") if "input" not in params or params.get("input") is None: raise ValueError("Ark Responses 创建需要 input。") @@ -1088,15 +1342,9 @@ def _prepare_batch_kwargs( params = dict(kwargs) if params.get("model") is None: params["model"] = model_name - missing = [ - name - for name in required - if name not in params or params[name] is None - ] + missing = [name for name in required if name not in params or params[name] is None] if missing: - raise ValueError( - f"Ark {resource} 缺少必填参数: {', '.join(missing)}" - ) + raise ValueError(f"Ark {resource} 缺少必填参数: {', '.join(missing)}") model = params.get("model") if not isinstance(model, str) or not model.strip(): raise ValueError(f"Ark {resource} 的 model 必须是非空字符串。") @@ -1113,11 +1361,13 @@ def _extract_result(response: Any) -> CompletionResult: text = content_to_text(field(message, "content", "")) for call in field(message, "tool_calls", []) or []: fn = field(call, "function") - calls.append({ - "id": field(call, "id"), - "name": field(fn, "name"), - "arguments": normalize_tool_arguments(field(fn, "arguments", "")), - }) + calls.append( + { + "id": field(call, "id"), + "name": field(fn, "name"), + "arguments": normalize_tool_arguments(field(fn, "arguments", "")), + } + ) output_text = field(response, "output_text") if isinstance(output_text, str): text = output_text @@ -1195,17 +1445,29 @@ def _extract_result(response: Any) -> CompletionResult: "Ark Responses 响应已完成但未包含任何正文、工具调用或拒答内容;" "请检查响应结构与 SDK 版本是否匹配。" ) - return CompletionResult(text=text, tool_calls=calls, usage=usage_dict, - finish_reason=field(choices[0], "finish_reason") if choices else field(response, "status"), - response_id=field(response, "id"), refusal=refusal, - reasoning=reasoning, raw=response) + return CompletionResult( + text=text, + tool_calls=calls, + usage=usage_dict, + finish_reason=field(choices[0], "finish_reason") + if choices + else field(response, "status"), + response_id=field(response, "id"), + refusal=refusal, + reasoning=reasoning, + raw=response, + ) @staticmethod def _raise_for_response_error(response: Any) -> None: status = field(response, "status") if status == "failed": error = field(response, "error") - message = error if isinstance(error, str) else field(error, "message", "Ark Responses 请求失败") + message = ( + error + if isinstance(error, str) + else field(error, "message", "Ark Responses 请求失败") + ) raise RuntimeError(str(message)) if status == "incomplete": details = field(response, "incomplete_details") @@ -1243,7 +1505,9 @@ def _chat_stream_events(chunk: Any) -> list[StreamEvent]: ) if isinstance(reasoning, str) and reasoning: events.append( - StreamEvent(type="reasoning_delta", reasoning=reasoning, response_id=response_id, raw=chunk) + StreamEvent( + type="reasoning_delta", reasoning=reasoning, response_id=response_id, raw=chunk + ) ) if calls: for position, call in enumerate(calls): @@ -1336,7 +1600,9 @@ def _responses_stream_event( response_error_handler=self._raise_for_response_error, ) - def stream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> Generator[StreamEvent, None, None]: + def stream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> Generator[StreamEvent, None, None]: request = coerce_completion_request(request, kwargs, "Ark stream_events").copy_with( stream=True, ) @@ -1424,12 +1690,19 @@ def stream_events(self, request: CompletionRequest | None = None, **kwargs: Any) if key not in completed_chat_tool_calls: validate_complete_tool_call(tool_call, "Ark Chat Completions") completed_chat_tool_calls.add(key) - yield StreamEvent(type="tool_call_completed", tool_call=dict(tool_call), response_id=converted.response_id, raw=converted.raw) + yield StreamEvent( + type="tool_call_completed", + tool_call=dict(tool_call), + response_id=converted.response_id, + raw=converted.raw, + ) yield converted if not terminal_seen: raise RuntimeError("Ark Chat Completions 流在 finish 事件之前结束。") - async def astream_events(self, request: CompletionRequest | None = None, **kwargs: Any) -> AsyncGenerator[StreamEvent, None]: + async def astream_events( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> AsyncGenerator[StreamEvent, None]: request = coerce_completion_request(request, kwargs, "Ark astream_events").copy_with( stream=True, ) @@ -1516,7 +1789,12 @@ async def astream_events(self, request: CompletionRequest | None = None, **kwarg if key not in completed_chat_tool_calls: validate_complete_tool_call(tool_call, "Ark Chat Completions") completed_chat_tool_calls.add(key) - yield StreamEvent(type="tool_call_completed", tool_call=dict(tool_call), response_id=converted.response_id, raw=converted.raw) + yield StreamEvent( + type="tool_call_completed", + tool_call=dict(tool_call), + response_id=converted.response_id, + raw=converted.raw, + ) yield converted if not terminal_seen: raise RuntimeError("Ark Chat Completions 异步流在 finish 事件之前结束。") @@ -1532,9 +1810,7 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C request = request.copy_with(stream=False) if self._protocol == "responses": self._require_responses_resource("complete") - response = self._get_client().responses.create( - **self._build_responses_request(request) - ) + response = self._get_client().responses.create(**self._build_responses_request(request)) self._raise_for_response_error(response) else: response = self._get_client().chat.completions.create( @@ -1546,7 +1822,9 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C ) return self._extract_result(response) - async def acomplete(self, request: CompletionRequest | None = None, **kwargs: Any) -> CompletionResult: + async def acomplete( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> CompletionResult: """使用 AsyncArk 聚合完整结果,保留 Responses/Chat 元数据。""" request = coerce_completion_request(request, kwargs, "Ark acomplete") request = request.copy_with(stream=False) @@ -1570,13 +1848,37 @@ async def acomplete(self, request: CompletionRequest | None = None, **kwargs: An ) return self._extract_result(response) - def invoke(self, prompt: str | None = None, system_prompt: str | None = "You are a helpful assistant.", - tools: list[dict[str, Any]] | None = None, stream: bool = True, - temperature: float | None = _UNSET_TEMPERATURE, # type: ignore[assignment] - messages: Any = None, max_tokens: int | None = None, top_p: float | None = None, - stop: str | list[str] | None = None, response_format: dict[str, Any] | None = None, - tool_choice: Any = None, extra_body: dict[str, Any] | None = None, **kwargs: Any) -> Generator[str, None, None]: - request = CompletionRequest(prompt=prompt, system_prompt=system_prompt, messages=messages, tools=tools, stream=stream, temperature=temperature, max_tokens=max_tokens, top_p=top_p, stop=stop, response_format=response_format, tool_choice=tool_choice, extra_body=extra_body, **kwargs) + def invoke( + self, + prompt: str | None = None, + system_prompt: str | None = "You are a helpful assistant.", + tools: list[dict[str, Any]] | None = None, + stream: bool = True, + temperature: float | None = _UNSET_TEMPERATURE, # type: ignore[assignment] + messages: Any = None, + max_tokens: int | None = None, + top_p: float | None = None, + stop: str | list[str] | None = None, + response_format: dict[str, Any] | None = None, + tool_choice: Any = None, + extra_body: dict[str, Any] | None = None, + **kwargs: Any, + ) -> Generator[str, None, None]: + request = CompletionRequest( + prompt=prompt, + system_prompt=system_prompt, + messages=messages, + tools=tools, + stream=stream, + temperature=temperature, + max_tokens=max_tokens, + top_p=top_p, + stop=stop, + response_format=response_format, + tool_choice=tool_choice, + extra_body=extra_body, + **kwargs, + ) if self._protocol == "responses": self._require_responses_resource("invoke") if tools: @@ -1649,10 +1951,15 @@ def invoke(self, prompt: str | None = None, system_prompt: str | None = "You are if result.text: yield result.text - async def ainvoke(self, prompt: str | None = None, system_prompt: str | None = "You are a helpful assistant.", - tools: list[dict[str, Any]] | None = None, stream: bool = True, - temperature: float | None = _UNSET_TEMPERATURE, # type: ignore[assignment] - **kwargs: Any) -> AsyncGenerator[str, None]: + async def ainvoke( + self, + prompt: str | None = None, + system_prompt: str | None = "You are a helpful assistant.", + tools: list[dict[str, Any]] | None = None, + stream: bool = True, + temperature: float | None = _UNSET_TEMPERATURE, # type: ignore[assignment] + **kwargs: Any, + ) -> AsyncGenerator[str, None]: if prompt is not None: kwargs["prompt"] = prompt kwargs["system_prompt"] = system_prompt @@ -1754,7 +2061,15 @@ def embed_documents(self, texts: list[str], **kwargs: Any) -> list[list[float]]: self._validate_resource_kwargs( "Embedding", request_options, - {"encoding_format", "dimensions", "user", "extra_body", "extra_headers", "extra_query", "timeout"}, + { + "encoding_format", + "dimensions", + "user", + "extra_body", + "extra_headers", + "extra_query", + "timeout", + }, ) request_extra_body = self._validated_extra_body( request_options.pop("extra_body", None), @@ -1792,7 +2107,15 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f self._validate_resource_kwargs( "Embedding", request_options, - {"encoding_format", "dimensions", "user", "extra_body", "extra_headers", "extra_query", "timeout"}, + { + "encoding_format", + "dimensions", + "user", + "extra_body", + "extra_headers", + "extra_query", + "timeout", + }, ) request_extra_body = self._validated_extra_body( request_options.pop("extra_body", None), @@ -1806,7 +2129,9 @@ async def aembed_documents(self, texts: list[str], **kwargs: Any) -> list[list[f ) if merged_extra_body: request["extra_body"] = merged_extra_body - response = await self._resolve_async_result(self._get_aclient().embeddings.create(**request)) + response = await self._resolve_async_result( + self._get_aclient().embeddings.create(**request) + ) return [normalize_embedding_vector(item.embedding) for item in response.data] def upload_file(self, file: Any, purpose: str, **kwargs: Any) -> Any: @@ -1820,12 +2145,16 @@ def list_files(self, **kwargs: Any) -> Any: def retrieve_file(self, file_id: str, **kwargs: Any) -> Any: file_id = self._require_non_empty_string(file_id, "Ark 文件 file_id") - self._validate_resource_kwargs("文件获取", kwargs, {"extra_headers", "extra_query", "extra_body", "timeout"}) + self._validate_resource_kwargs( + "文件获取", kwargs, {"extra_headers", "extra_query", "extra_body", "timeout"} + ) return self._get_client().files.retrieve(file_id, **kwargs) def delete_file(self, file_id: str, **kwargs: Any) -> Any: file_id = self._require_non_empty_string(file_id, "Ark 文件 file_id") - self._validate_resource_kwargs("文件删除", kwargs, {"extra_headers", "extra_query", "extra_body", "timeout"}) + self._validate_resource_kwargs( + "文件删除", kwargs, {"extra_headers", "extra_query", "extra_body", "timeout"} + ) return self._get_client().files.delete(file_id, **kwargs) def wait_for_file( @@ -1978,8 +2307,14 @@ def delete_content_generation_task(self, task_id: str, **kwargs: Any) -> Any: return self._get_client().content_generation.tasks.delete(task_id=task_id, **kwargs) def count_tokens(self, text: str | list[str], **kwargs: Any) -> int: - self._validate_resource_kwargs("Tokenization", kwargs, {"user", "extra_headers", "extra_query", "extra_body", "timeout"}) - response = self._get_client().tokenization.create(text=text, model=self._model_name, **kwargs) + self._validate_resource_kwargs( + "Tokenization", + kwargs, + {"user", "extra_headers", "extra_query", "extra_body", "timeout"}, + ) + response = self._get_client().tokenization.create( + text=text, model=self._model_name, **kwargs + ) values = field(response, "data", []) or [] if not values: raise RuntimeError("Ark tokenization 响应缺少 data。") @@ -1989,9 +2324,20 @@ def multimodal_embed(self, inputs: Any, **kwargs: Any) -> Any: self._validate_resource_kwargs( "Multimodal Embedding", kwargs, - {"encoding_format", "dimensions", "instructions", "sparse_embedding", "extra_headers", "extra_query", "extra_body", "timeout"}, + { + "encoding_format", + "dimensions", + "instructions", + "sparse_embedding", + "extra_headers", + "extra_query", + "extra_body", + "timeout", + }, + ) + return self._get_client().multimodal_embeddings.create( + input=inputs, model=self._model_name, **kwargs ) - return self._get_client().multimodal_embeddings.create(input=inputs, model=self._model_name, **kwargs) def list_input_items(self, response_id: str, **kwargs: Any) -> Any: self._require_responses_resource("list_input_items") @@ -2062,8 +2408,10 @@ def classify( ) -> Any: """调用 Ark Classification 接口。""" query = self._require_non_empty_string(query, "Ark Classification 的 query") - if not isinstance(labels, list) or not labels or any( - not isinstance(label, str) or not label.strip() for label in labels + if ( + not isinstance(labels, list) + or not labels + or any(not isinstance(label, str) or not label.strip() for label in labels) ): raise ValueError("Ark Classification 的 labels 必须是非空字符串列表。") labels = [label.strip() for label in labels] @@ -2087,7 +2435,9 @@ def classify( async def async_upload_file(self, file: Any, purpose: str, **kwargs: Any) -> Any: purpose = self._require_non_empty_string(purpose, "Ark 文件 purpose") self._validate_resource_kwargs("文件创建", kwargs, self._ARK_FILE_CREATE_KEYS) - return await self._resolve_async_result(self._get_aclient().files.create(file=file, purpose=purpose, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().files.create(file=file, purpose=purpose, **kwargs) + ) async def async_list_files(self, **kwargs: Any) -> Any: self._validate_resource_kwargs("文件列表", kwargs, self._ARK_FILE_LIST_KEYS) @@ -2095,12 +2445,18 @@ async def async_list_files(self, **kwargs: Any) -> Any: async def async_retrieve_file(self, file_id: str, **kwargs: Any) -> Any: file_id = self._require_non_empty_string(file_id, "Ark 文件 file_id") - self._validate_resource_kwargs("文件获取", kwargs, {"extra_headers", "extra_query", "extra_body", "timeout"}) - return await self._resolve_async_result(self._get_aclient().files.retrieve(file_id, **kwargs)) + self._validate_resource_kwargs( + "文件获取", kwargs, {"extra_headers", "extra_query", "extra_body", "timeout"} + ) + return await self._resolve_async_result( + self._get_aclient().files.retrieve(file_id, **kwargs) + ) async def async_delete_file(self, file_id: str, **kwargs: Any) -> Any: file_id = self._require_non_empty_string(file_id, "Ark 文件 file_id") - self._validate_resource_kwargs("文件删除", kwargs, {"extra_headers", "extra_query", "extra_body", "timeout"}) + self._validate_resource_kwargs( + "文件删除", kwargs, {"extra_headers", "extra_query", "extra_body", "timeout"} + ) return await self._resolve_async_result(self._get_aclient().files.delete(file_id, **kwargs)) async def async_wait_for_file( @@ -2135,9 +2491,13 @@ async def async_retrieve_response(self, response_id: str, **kwargs: Any) -> Any: self._validate_resource_kwargs( "Responses retrieve_response", kwargs, self._ARK_RESPONSES_RETRIEVE_KEYS ) - return await self._resolve_async_result(self._get_aclient().responses.retrieve(response_id, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().responses.retrieve(response_id, **kwargs) + ) - async def async_create_response(self, request: CompletionRequest | None = None, **kwargs: Any) -> Any: + async def async_create_response( + self, request: CompletionRequest | None = None, **kwargs: Any + ) -> Any: """异步创建 Ark Responses;可传统一请求或原生 SDK 参数。""" self._require_responses_resource("create_response") if request is not None: @@ -2153,7 +2513,9 @@ async def async_delete_response(self, response_id: str, **kwargs: Any) -> Any: self._validate_resource_kwargs( "Responses delete_response", kwargs, self._ARK_RESPONSES_RETRIEVE_KEYS ) - return await self._resolve_async_result(self._get_aclient().responses.delete(response_id, **kwargs)) + return await self._resolve_async_result( + self._get_aclient().responses.delete(response_id, **kwargs) + ) async def async_list_response_input_items(self, response_id: str, **kwargs: Any) -> Any: self._require_responses_resource("list_response_input_items") @@ -2287,10 +2649,14 @@ async def async_delete_content_generation_task(self, task_id: str, **kwargs: Any ) async def async_count_tokens(self, text: str | list[str], **kwargs: Any) -> int: - self._validate_resource_kwargs("Tokenization", kwargs, {"user", "extra_headers", "extra_query", "extra_body", "timeout"}) - response = await self._resolve_async_result(self._get_aclient().tokenization.create( - text=text, model=self._model_name, **kwargs - )) + self._validate_resource_kwargs( + "Tokenization", + kwargs, + {"user", "extra_headers", "extra_query", "extra_body", "timeout"}, + ) + response = await self._resolve_async_result( + self._get_aclient().tokenization.create(text=text, model=self._model_name, **kwargs) + ) values = field(response, "data", []) or [] if not values: raise RuntimeError("Ark tokenization 响应缺少 data。") @@ -2300,11 +2666,22 @@ async def async_multimodal_embed(self, inputs: Any, **kwargs: Any) -> Any: self._validate_resource_kwargs( "Multimodal Embedding", kwargs, - {"encoding_format", "dimensions", "instructions", "sparse_embedding", "extra_headers", "extra_query", "extra_body", "timeout"}, + { + "encoding_format", + "dimensions", + "instructions", + "sparse_embedding", + "extra_headers", + "extra_query", + "extra_body", + "timeout", + }, + ) + return await self._resolve_async_result( + self._get_aclient().multimodal_embeddings.create( + input=inputs, model=self._model_name, **kwargs + ) ) - return await self._resolve_async_result(self._get_aclient().multimodal_embeddings.create( - input=inputs, model=self._model_name, **kwargs - )) async def async_generate_image(self, **kwargs: Any) -> Any: self._validate_resource_kwargs("Images", kwargs, self._ARK_IMAGE_GENERATE_KEYS) @@ -2314,7 +2691,9 @@ async def async_generate_image(self, **kwargs: Any) -> Any: async def async_beta_chat_parse(self, **kwargs: Any) -> Any: self._validate_resource_kwargs("Beta Chat Parse", kwargs, self._ARK_BETA_CHAT_KEYS) kwargs.setdefault("model", self._model_name) - return await self._resolve_async_result(self._get_aclient().beta.chat.completions.parse(**kwargs)) + return await self._resolve_async_result( + self._get_aclient().beta.chat.completions.parse(**kwargs) + ) def async_beta_chat_stream(self, **kwargs: Any) -> Any: """返回 Ark 异步 Beta Chat 的原生流式上下文管理器。 @@ -2330,7 +2709,9 @@ def async_beta_chat_stream(self, **kwargs: Any) -> Any: async def async_bot_chat(self, **kwargs: Any) -> Any: self._validate_resource_kwargs("Bot Chat", kwargs, self._ARK_BOT_CHAT_KEYS) kwargs.setdefault("model", self._model_name) - return await self._resolve_async_result(self._get_aclient().bot_chat.completions.create(**kwargs)) + return await self._resolve_async_result( + self._get_aclient().bot_chat.completions.create(**kwargs) + ) async def _get_async_classification_resource(self) -> Any: """返回 Ark Async Classification 资源,保持与同步入口相同的显式边界。""" @@ -2362,8 +2743,10 @@ async def async_classify( ) -> Any: """异步调用 Ark Classification 接口。""" query = self._require_non_empty_string(query, "Ark Classification 的 query") - if not isinstance(labels, list) or not labels or any( - not isinstance(label, str) or not label.strip() for label in labels + if ( + not isinstance(labels, list) + or not labels + or any(not isinstance(label, str) or not label.strip() for label in labels) ): raise ValueError("Ark Classification 的 labels 必须是非空字符串列表。") labels = [label.strip() for label in labels] @@ -2378,12 +2761,14 @@ async def async_classify( {"user", "extra_headers", "extra_query", "extra_body", "timeout"}, ) resource = await self._get_async_classification_resource() - return await self._resolve_async_result(resource.create( - query=query, - model=effective_model, - labels=labels, - **kwargs, - )) + return await self._resolve_async_result( + resource.create( + query=query, + model=effective_model, + labels=labels, + **kwargs, + ) + ) def close(self) -> None: """释放同步和异步 Ark SDK 客户端及分类资源。""" diff --git a/src/retrieval/retriever.py b/src/retrieval/retriever.py index af83278..a1cb9e7 100644 --- a/src/retrieval/retriever.py +++ b/src/retrieval/retriever.py @@ -122,6 +122,8 @@ async def aretrieve_documents( embedding_service=EmbeddingService(run_config), ) try: - return await retrieval_service.retrieve(query=query, session_config=session_config, console=console) + return await retrieval_service.retrieve( + query=query, session_config=session_config, console=console + ) finally: await retrieval_service.aclose() diff --git a/src/retrieval/vdb/base.py b/src/retrieval/vdb/base.py index 339b404..d84ae50 100644 --- a/src/retrieval/vdb/base.py +++ b/src/retrieval/vdb/base.py @@ -21,11 +21,15 @@ async def aadd_documents(self, documents: list[dict[str, Any]]): """异步添加文档。""" @abstractmethod - def search(self, query: str, top_k: int = 5, search_type: str = "semantic") -> list[dict[str, Any]]: + def search( + self, query: str, top_k: int = 5, search_type: str = "semantic" + ) -> list[dict[str, Any]]: """同步搜索文档。""" @abstractmethod - async def asearch(self, query: str, top_k: int = 5, search_type: str = "semantic") -> list[dict[str, Any]]: + async def asearch( + self, query: str, top_k: int = 5, search_type: str = "semantic" + ) -> list[dict[str, Any]]: """异步搜索文档。""" @abstractmethod diff --git a/src/retrieval/vdb/factory.py b/src/retrieval/vdb/factory.py index 0f4a24d..4ead455 100644 --- a/src/retrieval/vdb/factory.py +++ b/src/retrieval/vdb/factory.py @@ -39,8 +39,7 @@ def _load_existing_state(store: VectorStoreBase, run_config) -> None: ) if embedding_detail is None: raise ValueError( - "当前 embedding 配置缺少活动提供商: " - f"{run_config.default_embedding_provider}" + f"当前 embedding 配置缺少活动提供商: {run_config.default_embedding_provider}" ) if ( manifest.embedding_provider != run_config.default_embedding_provider @@ -67,10 +66,14 @@ def _load_existing_state(store: VectorStoreBase, run_config) -> None: snapshot_id=snapshot_id, store_type=run_config.default_vector_store, embedding_provider=run_config.default_embedding_provider, - embedding_model=run_config.embedding_configurations[run_config.default_embedding_provider].model_name, + embedding_model=run_config.embedding_configurations[ + run_config.default_embedding_provider + ].model_name, chunk_mode="legacy-import", source_digest="legacy-import", - document_count=len({doc.get("metadata", {}).get("source") for doc in getattr(store, "documents", [])}), + document_count=len( + {doc.get("metadata", {}).get("source") for doc in getattr(store, "documents", [])} + ), chunk_count=len(getattr(store, "documents", [])), ) snapshot_repository.write_manifest(temp_dir, manifest) diff --git a/src/retrieval/vdb/faiss_store.py b/src/retrieval/vdb/faiss_store.py index e3a0b42..9ab83fb 100644 --- a/src/retrieval/vdb/faiss_store.py +++ b/src/retrieval/vdb/faiss_store.py @@ -72,7 +72,9 @@ def _build_index_text(document: dict[str, Any]) -> str: def _rebuild_indices(self) -> None: if self.documents: - self._tokenized_docs_cache = [list(jieba.cut(self._build_index_text(doc))) for doc in self.documents] + self._tokenized_docs_cache = [ + list(jieba.cut(self._build_index_text(doc))) for doc in self.documents + ] self.bm25_index = BM25Okapi(self._tokenized_docs_cache) else: self._tokenized_docs_cache = [] @@ -138,7 +140,9 @@ def upsert_embeddings(self, documents: list[dict[str, Any]], embeddings: Any): self._rebuild_indices() def add_documents(self, documents: list[dict[str, Any]]): - raise RuntimeError("FaissStore.add_documents 已废弃,请使用外部 EmbeddingService 后调用 upsert_embeddings。") + raise RuntimeError( + "FaissStore.add_documents 已废弃,请使用外部 EmbeddingService 后调用 upsert_embeddings。" + ) async def aadd_documents(self, documents: list[dict[str, Any]]): raise RuntimeError("FaissStore.aadd_documents 已废弃,请使用 KnowledgeBuildService。") @@ -189,11 +193,19 @@ def keyword_search(self, query_text: str, top_k: int = 5) -> list[dict[str, Any] break return results - def search(self, query: str, top_k: int = 5, search_type: str = "semantic") -> list[dict[str, Any]]: - raise RuntimeError("FaissStore.search 已废弃,请通过 RetrievalService 调用语义检索或关键词检索。") + def search( + self, query: str, top_k: int = 5, search_type: str = "semantic" + ) -> list[dict[str, Any]]: + raise RuntimeError( + "FaissStore.search 已废弃,请通过 RetrievalService 调用语义检索或关键词检索。" + ) - async def asearch(self, query: str, top_k: int = 5, search_type: str = "semantic") -> list[dict[str, Any]]: - raise RuntimeError("FaissStore.asearch 已废弃,请通过 RetrievalService 调用语义检索或关键词检索。") + async def asearch( + self, query: str, top_k: int = 5, search_type: str = "semantic" + ) -> list[dict[str, Any]]: + raise RuntimeError( + "FaissStore.asearch 已废弃,请通过 RetrievalService 调用语义检索或关键词检索。" + ) def save(self, path: str): with open(path, "wb") as file: @@ -233,10 +245,14 @@ def save_snapshot(self, snapshot_dir: str): np.save(snapshot_path / "embeddings.npy", self.embeddings) faiss.write_index(self.faiss_index, str(snapshot_path / "semantic.index")) stats = { - "document_count": len({doc.get("metadata", {}).get("source") for doc in self.documents}), + "document_count": len( + {doc.get("metadata", {}).get("source") for doc in self.documents} + ), "chunk_count": len(self.documents), "parent_count": len(self.parent_documents), - "embedding_dimension": int(self.embeddings.shape[1]) if self.embeddings is not None else 0, + "embedding_dimension": int(self.embeddings.shape[1]) + if self.embeddings is not None + else 0, } (snapshot_path / "stats.json").write_text( json.dumps(stats, ensure_ascii=False, indent=2), @@ -255,7 +271,9 @@ def load_snapshot(self, snapshot_dir: str): embeddings_path = snapshot_path / "embeddings.npy" self.embeddings = np.load(embeddings_path) if embeddings_path.exists() else None faiss_index_path = snapshot_path / "semantic.index" - self.faiss_index = faiss.read_index(str(faiss_index_path)) if faiss_index_path.exists() else None + self.faiss_index = ( + faiss.read_index(str(faiss_index_path)) if faiss_index_path.exists() else None + ) lexical_path = snapshot_path / "lexical.index" if lexical_path.exists(): with lexical_path.open("rb") as file: diff --git a/src/retrieval_test/core.py b/src/retrieval_test/core.py index 961e1de..74e0d5c 100644 --- a/src/retrieval_test/core.py +++ b/src/retrieval_test/core.py @@ -27,7 +27,9 @@ def display_results(query: str, documents: list[dict]): if not documents: - console.print(Panel(f"对查询 “[bold yellow]{query}[/bold yellow]” 无结果。", border_style="red")) + console.print( + Panel(f"对查询 “[bold yellow]{query}[/bold yellow]” 无结果。", border_style="red") + ) return table = Table(title=f"“{query}” 召回测试", show_header=True) @@ -44,7 +46,6 @@ def display_results(query: str, documents: list[dict]): console.print(table) - async def _aclose_quietly(service: Any) -> None: """释放服务持有的资源,失败只记录警告,不打断退出流程。""" close = getattr(service, "aclose", None) or getattr(service, "close", None) @@ -80,14 +81,18 @@ async def run_retrieval_test_async(): try: while True: try: - query = await session.prompt_async(HTML('测试查询: ')) + query = await session.prompt_async( + HTML("测试查询: ") + ) if query.lower() == "/quit": break if not query: continue with console.status("[bold green]正在异步检索...[/bold green]"): - documents = await retrieval_service.retrieve(query, session_config, console=console) + documents = await retrieval_service.retrieve( + query, session_config, console=console + ) display_results(query, documents) if excel_logger: excel_logger.log_results(query, documents) diff --git a/src/retrieval_test/excel_logger.py b/src/retrieval_test/excel_logger.py index 3be8971..6c92bf9 100644 --- a/src/retrieval_test/excel_logger.py +++ b/src/retrieval_test/excel_logger.py @@ -10,10 +10,12 @@ logger = get_module_logger(__name__) + class ExcelLogger: """ 一个用于将召回测试结果记录到 Excel 文件的日志记录器。 """ + def __init__(self, log_dir: str = "data/logs"): """ 初始化 ExcelLogger。 @@ -23,19 +25,19 @@ def __init__(self, log_dir: str = "data/logs"): """ # 确保日志目录存在 os.makedirs(log_dir, exist_ok=True) - + # 生成带时间戳的唯一文件名 timestamp = datetime.now().astimezone().strftime("%Y%m%d_%H%M%S") self.filepath = os.path.join(log_dir, f"recall_test_log_{timestamp}.xlsx") - + # 创建一个新的 Excel 工作簿和工作表 self.workbook = Workbook() - self.worksheet: Worksheet = self.workbook.active # type: ignore + self.worksheet: Worksheet = self.workbook.active # type: ignore self.worksheet.title = "Recall Test Log" - + # 写入表头 self._write_header() - + logger.info(f"Excel 日志记录器初始化成功,日志将保存至: {self.filepath}") def _write_header(self): @@ -48,7 +50,7 @@ def _write_header(self): "文档来源 (Source)", "页码 (Page)", "文档内容 (Content)", - "详细分数 (All Scores)" + "详细分数 (All Scores)", ] self.worksheet.append(headers) self.workbook.save(self.filepath) @@ -62,7 +64,7 @@ def log_results(self, query: str, results: list[dict[str, Any]]): results (List[Dict[str, Any]]): 从检索器返回的文档列表。 """ timestamp = datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S") - + row: list[Any] if not results: # 如果没有结果,也记录一条信息 @@ -76,28 +78,19 @@ def log_results(self, query: str, results: list[dict[str, Any]]): metadata = doc.get("metadata", {}) source = metadata.get("source", "未知") page = metadata.get("page", "N/A") - + # 提取所有可用的分数 all_scores = { "score": score, "semantic_score": doc.get("semantic_score"), - "keyword_score": doc.get("keyword_score") + "keyword_score": doc.get("keyword_score"), } # 过滤掉值为 None 的分数 all_scores_str = json.dumps({k: v for k, v in all_scores.items() if v is not None}) - row = [ - timestamp, - query, - rank, - score, - source, - page, - content, - all_scores_str - ] + row = [timestamp, query, rank, score, source, page, content, all_scores_str] self.worksheet.append(row) - + try: self.workbook.save(self.filepath) logger.debug(f"成功将查询 '{query}' 的 {len(results)} 条结果记录到 {self.filepath}") diff --git a/src/services/chat_service.py b/src/services/chat_service.py index 4cc7aa6..8c79608 100644 --- a/src/services/chat_service.py +++ b/src/services/chat_service.py @@ -38,7 +38,9 @@ async def identify_intent(self, user_query: str) -> str: raise RuntimeError("意图识别返回空结果,无法继续检索。") return intent - async def retrieve(self, user_input: str, session_config: SessionConfig, console: Any) -> tuple[str, list[dict[str, Any]]]: + async def retrieve( + self, user_input: str, session_config: SessionConfig, console: Any + ) -> tuple[str, list[dict[str, Any]]]: intent = await self.identify_intent(user_input) documents = await self.retrieval_service.retrieve(intent, session_config, console=console) return intent, documents @@ -71,8 +73,4 @@ def _build_prompt(user_input: str, intent: str, documents: list[dict[str, Any]]) f"知识:\n{context}\n" "回答要简洁。" ) - return ( - "你是一个智能客服。\n" - f"用户问题: {user_input}\n" - "没有找到相关知识。请礼貌告知。" - ) + return f"你是一个智能客服。\n用户问题: {user_input}\n没有找到相关知识。请礼貌告知。" diff --git a/src/services/knowledge_build_service.py b/src/services/knowledge_build_service.py index 2a809c5..c31e657 100644 --- a/src/services/knowledge_build_service.py +++ b/src/services/knowledge_build_service.py @@ -34,13 +34,17 @@ def __init__( async def build(self, splitter_structure_mode: str) -> dict[str, object]: markdown_files = sorted(self.run_config.knowledge_base_path.glob("*.md")) if not markdown_files: - raise FileNotFoundError(f"知识库目录 '{self.run_config.knowledge_base_path}' 中未找到 Markdown 文件。") + raise FileNotFoundError( + f"知识库目录 '{self.run_config.knowledge_base_path}' 中未找到 Markdown 文件。" + ) snapshot_id = self.snapshot_repository.generate_snapshot_id() temp_dir = self.snapshot_repository.create_temp_snapshot_dir(snapshot_id) finalized = False try: - pipeline = Pipeline.from_file_path(markdown_files[0], splitter_structure_mode=splitter_structure_mode) + pipeline = Pipeline.from_file_path( + markdown_files[0], splitter_structure_mode=splitter_structure_mode + ) chunk_count = 0 pending_documents: list[dict[str, Any]] = [] @@ -48,7 +52,9 @@ async def build(self, splitter_structure_mode: str) -> dict[str, object]: pending_parent_documents: dict[str, dict[str, Any]] = {} for file_path in markdown_files: chunks = self._process_file(pipeline, file_path) - if splitter_structure_mode == "hierarchical" and hasattr(pipeline.splitter, "parent_documents"): + if splitter_structure_mode == "hierarchical" and hasattr( + pipeline.splitter, "parent_documents" + ): parent_documents = getattr(pipeline.splitter, "parent_documents", {}) if isinstance(parent_documents, dict): pending_parent_documents.update(parent_documents) diff --git a/src/services/retrieval_service.py b/src/services/retrieval_service.py index 740365f..c1575d1 100644 --- a/src/services/retrieval_service.py +++ b/src/services/retrieval_service.py @@ -23,7 +23,13 @@ class HybridReranker: - def __init__(self, vector_weight: float, keyword_weight: float, fusion_strategy: str = "rrf", rrf_k: float = DEFAULT_RRF_K): + def __init__( + self, + vector_weight: float, + keyword_weight: float, + fusion_strategy: str = "rrf", + rrf_k: float = DEFAULT_RRF_K, + ): self.vector_weight = vector_weight self.keyword_weight = keyword_weight self.fusion_strategy = fusion_strategy.strip().lower() @@ -61,7 +67,10 @@ def _weighted_rerank(self, documents: list[dict[str, Any]]) -> list[dict[str, An normalized_keyword = self._normalize_scores(keyword_scores) normalized_semantic = self._normalize_scores(semantic_scores) for index, doc in enumerate(scored_documents): - doc["score"] = vector_weight * normalized_semantic[index] + keyword_weight * normalized_keyword[index] + doc["score"] = ( + vector_weight * normalized_semantic[index] + + keyword_weight * normalized_keyword[index] + ) return sorted(scored_documents, key=lambda item: item["score"], reverse=True) def _rrf_rerank(self, documents: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -88,7 +97,9 @@ def _document_key(document: dict[str, Any]) -> str: return f"{source}:{document.get('page_content', '')[:64]}" -def _merge_hybrid_results(semantic_results: list[dict[str, Any]], keyword_results: list[dict[str, Any]]) -> list[dict[str, Any]]: +def _merge_hybrid_results( + semantic_results: list[dict[str, Any]], keyword_results: list[dict[str, Any]] +) -> list[dict[str, Any]]: merged: dict[str, dict[str, Any]] = {} for rank, document in enumerate(semantic_results, start=1): merged_doc = copy.deepcopy(document) @@ -110,7 +121,9 @@ def _merge_hybrid_results(semantic_results: list[dict[str, Any]], keyword_result return list(merged.values()) -def _promote_parent_context(documents: list[dict[str, Any]], resolver: Callable[[str | None], str | None] | None) -> list[dict[str, Any]]: +def _promote_parent_context( + documents: list[dict[str, Any]], resolver: Callable[[str | None], str | None] | None +) -> list[dict[str, Any]]: promoted_documents: list[dict[str, Any]] = [] for document in documents: promoted_doc = copy.deepcopy(document) @@ -131,7 +144,9 @@ def _deduplicate_parent_documents(documents: list[dict[str, Any]]) -> list[dict[ parent_key = metadata.get("parent_id") key = str(parent_key) if parent_key else _document_key(document) existing = deduplicated.get(key) - if existing is None or float(document.get("score", 0) or 0) > float(existing.get("score", 0) or 0): + if existing is None or float(document.get("score", 0) or 0) > float( + existing.get("score", 0) or 0 + ): deduplicated[key] = copy.deepcopy(document) return list(deduplicated.values()) @@ -142,33 +157,47 @@ def __init__(self, vector_store: VectorStoreBase, embedding_service: EmbeddingSe self.embedding_service = embedding_service self._rerank_providers: dict[tuple[str, str, str, str], RerankModel] = {} - async def retrieve(self, query: str, session_config: SessionConfig, console: Console | None = None) -> list[dict[str, Any]]: + async def retrieve( + self, query: str, session_config: SessionConfig, console: Console | None = None + ) -> list[dict[str, Any]]: retrieval_method = session_config.retrieval_method - effective_top_k = max(1, session_config.top_k) * max(1, session_config.retrieval_candidate_multiplier) + effective_top_k = max(1, session_config.top_k) * max( + 1, session_config.retrieval_candidate_multiplier + ) parent_resolver = getattr(self.vector_store, "resolve_parent_content", None) if retrieval_method == RetrievalMethod.HYBRID_SEARCH: - semantic_results, keyword_results = await self._gather_hybrid_results(query, effective_top_k) + semantic_results, keyword_results = await self._gather_hybrid_results( + query, effective_top_k + ) reranker = HybridReranker( session_config.vector_weight, session_config.keyword_weight, fusion_strategy=session_config.hybrid_fusion_strategy, ) - ranked_results = reranker.rerank(_merge_hybrid_results(semantic_results, keyword_results)) + ranked_results = reranker.rerank( + _merge_hybrid_results(semantic_results, keyword_results) + ) elif retrieval_method == RetrievalMethod.SEMANTIC_SEARCH: ranked_results = await self._semantic_retrieve(query, effective_top_k) else: ranked_results = await self._keyword_retrieve(query, effective_top_k) if self._should_apply_score_threshold(session_config): - ranked_results = [doc for doc in ranked_results if float(doc.get("score", 0) or 0) >= session_config.score_threshold] + ranked_results = [ + doc + for doc in ranked_results + if float(doc.get("score", 0) or 0) >= session_config.score_threshold + ] ranked_results = _promote_parent_context(ranked_results, parent_resolver) ranked_results = _deduplicate_parent_documents(ranked_results) ranked_results = await self._rerank_if_needed(query, ranked_results, session_config) return ranked_results[: max(1, session_config.top_k)] - async def _gather_hybrid_results(self, query: str, top_k: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + async def _gather_hybrid_results( + self, query: str, top_k: int + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: semantic_results, keyword_results = await asyncio.gather( self._semantic_retrieve(query, top_k), self._keyword_retrieve(query, top_k), @@ -207,7 +236,9 @@ async def _call_vector_store(self, method_name: str, *args) -> list[dict[str, An raise NotImplementedError(f"向量存储未实现 {method_name} 接口。") return await asyncio.to_thread(method, *args) - async def _legacy_search(self, query: str, top_k: int, search_type: str) -> list[dict[str, Any]]: + async def _legacy_search( + self, query: str, top_k: int, search_type: str + ) -> list[dict[str, Any]]: async_search = getattr(self.vector_store, "asearch", None) if callable(async_search): return await async_search(query, top_k, search_type=search_type) @@ -242,8 +273,7 @@ def _get_rerank_provider( # its replacement so stale transports and credentials are not kept # alive indefinitely. stale_keys = [ - key for key in self._rerank_providers - if key[0] == provider_key and key != cache_key + key for key in self._rerank_providers if key[0] == provider_key and key != cache_key ] for stale_key in stale_keys: stale_provider = self._rerank_providers.pop(stale_key) @@ -301,7 +331,9 @@ async def aclose(self) -> None: if inspect.isawaitable(result): await result - async def _rerank_if_needed(self, query: str, ranked_results: list[dict[str, Any]], session_config: SessionConfig) -> list[dict[str, Any]]: + async def _rerank_if_needed( + self, query: str, ranked_results: list[dict[str, Any]], session_config: SessionConfig + ) -> list[dict[str, Any]]: if not session_config.rerank_enabled or not ranked_results: return ranked_results diff --git a/src/ui/config_menu.py b/src/ui/config_menu.py index a00d54b..1f32ef8 100644 --- a/src/ui/config_menu.py +++ b/src/ui/config_menu.py @@ -8,19 +8,22 @@ from ..utils.log_manager import get_module_logger # 导入日志管理器 from .display_utils import display_chat_config -logger = get_module_logger(__name__) # 获取当前模块的日志器 +logger = get_module_logger(__name__) # 获取当前模块的日志器 console = Console() + def edit_retrieval_params(chat_config: dict[str, Any]) -> None: """编辑检索相关参数。""" logger.info("进入检索参数编辑菜单。") while True: - current_method = chat_config['retrieval_method'].value - current_top_k = chat_config['top_k'] - current_rerank = "启用" if chat_config['rerank_enabled'] else "禁用" - current_weights = f"向量: {chat_config['vector_weight']} / 关键词: {chat_config['keyword_weight']}" - current_fusion_strategy = chat_config['hybrid_fusion_strategy'].upper() - current_candidate_multiplier = chat_config['retrieval_candidate_multiplier'] + current_method = chat_config["retrieval_method"].value + current_top_k = chat_config["top_k"] + current_rerank = "启用" if chat_config["rerank_enabled"] else "禁用" + current_weights = ( + f"向量: {chat_config['vector_weight']} / 关键词: {chat_config['keyword_weight']}" + ) + current_fusion_strategy = chat_config["hybrid_fusion_strategy"].upper() + current_candidate_multiplier = chat_config["retrieval_candidate_multiplier"] choice = questionary.select( "选择要调整的检索参数:", @@ -29,12 +32,17 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None: questionary.Choice(f"2. 检索数量 Top K (当前: {current_top_k})", value="top_k"), questionary.Choice(f"3. Rerank重排 (当前: {current_rerank})", value="rerank"), questionary.Choice(f"4. 混合搜索权重 (当前: {current_weights})", value="weights"), - questionary.Choice(f"5. 混合融合策略 (当前: {current_fusion_strategy})", value="fusion"), - questionary.Choice(f"6. 候选过量招募倍率 (当前: {current_candidate_multiplier})", value="candidate_multiplier"), + questionary.Choice( + f"5. 混合融合策略 (当前: {current_fusion_strategy})", value="fusion" + ), + questionary.Choice( + f"6. 候选过量招募倍率 (当前: {current_candidate_multiplier})", + value="candidate_multiplier", + ), questionary.Separator(), - questionary.Choice("返回主菜单", value="back") + questionary.Choice("返回主菜单", value="back"), ], - style=questionary.Style([('pointer', 'bold fg:yellow')]), + style=questionary.Style([("pointer", "bold fg:yellow")]), ).ask() if choice == "back" or choice is None: @@ -47,12 +55,14 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None: choices=[ questionary.Choice("全文检索", value=RetrievalMethod.FULL_TEXT_SEARCH.value), questionary.Choice("向量检索", value=RetrievalMethod.SEMANTIC_SEARCH.value), - questionary.Choice("混合检索 (全文+向量)", value=RetrievalMethod.HYBRID_SEARCH.value) + questionary.Choice( + "混合检索 (全文+向量)", value=RetrievalMethod.HYBRID_SEARCH.value + ), ], - default=current_method + default=current_method, ).ask() if new_method: - chat_config['retrieval_method'] = RetrievalMethod(new_method) + chat_config["retrieval_method"] = RetrievalMethod(new_method) console.print(f"[green]检索模式已更新为: {new_method}[/green]") logger.info(f"检索模式已更新为: {new_method}") @@ -60,28 +70,27 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None: new_top_k = questionary.text( f"输入新的Top K值 (当前: {current_top_k}):", validate=lambda text: text.isdigit() and int(text) > 0, - default=str(current_top_k) + default=str(current_top_k), ).ask() if new_top_k: - chat_config['top_k'] = int(new_top_k) + chat_config["top_k"] = int(new_top_k) console.print(f"[green]Top K 已更新为: {new_top_k}[/green]") logger.info(f"Top K 已更新为: {new_top_k}") elif choice == "rerank": new_rerank = questionary.confirm( - "是否启用Rerank重排?", - default=chat_config['rerank_enabled'] + "是否启用Rerank重排?", default=chat_config["rerank_enabled"] ).ask() if new_rerank is not None: - chat_config['rerank_enabled'] = new_rerank + chat_config["rerank_enabled"] = new_rerank console.print(f"[green]Rerank已更新为: {'启用' if new_rerank else '禁用'}[/green]") logger.info(f"Rerank已更新为: {'启用' if new_rerank else '禁用'}") - + elif choice == "weights": - if chat_config['retrieval_method'] != RetrievalMethod.HYBRID_SEARCH: + if chat_config["retrieval_method"] != RetrievalMethod.HYBRID_SEARCH: console.print("[yellow]警告: 权重调整仅在 '混合检索' 模式下生效。[/yellow]") logger.warning("尝试调整权重,但当前检索模式不是混合检索。") - + def is_float_between_0_and_1(text): try: val = float(text) @@ -92,19 +101,23 @@ def is_float_between_0_and_1(text): new_vector_weight_str = questionary.text( f"输入新的向量权重 (0.0-1.0, 当前: {chat_config['vector_weight']}):", validate=is_float_between_0_and_1, - default=str(chat_config['vector_weight']) + default=str(chat_config["vector_weight"]), ).ask() if new_vector_weight_str: new_vector_weight = float(new_vector_weight_str) new_keyword_weight = 1.0 - new_vector_weight - chat_config['vector_weight'] = new_vector_weight - chat_config['keyword_weight'] = round(new_keyword_weight, 2) - console.print(f"[green]混合搜索权重已更新为 -> 向量: {chat_config['vector_weight']}, 关键词: {chat_config['keyword_weight']}[/green]") - logger.info(f"混合搜索权重已更新为 -> 向量: {chat_config['vector_weight']}, 关键词: {chat_config['keyword_weight']}") + chat_config["vector_weight"] = new_vector_weight + chat_config["keyword_weight"] = round(new_keyword_weight, 2) + console.print( + f"[green]混合搜索权重已更新为 -> 向量: {chat_config['vector_weight']}, 关键词: {chat_config['keyword_weight']}[/green]" + ) + logger.info( + f"混合搜索权重已更新为 -> 向量: {chat_config['vector_weight']}, 关键词: {chat_config['keyword_weight']}" + ) elif choice == "fusion": - if chat_config['retrieval_method'] != RetrievalMethod.HYBRID_SEARCH: + if chat_config["retrieval_method"] != RetrievalMethod.HYBRID_SEARCH: console.print("[yellow]警告: 融合策略仅在 '混合检索' 模式下生效。[/yellow]") logger.warning("尝试调整融合策略,但当前检索模式不是混合检索。") @@ -114,10 +127,10 @@ def is_float_between_0_and_1(text): questionary.Choice("RRF (倒数排名融合)", value="rrf"), questionary.Choice("Weighted (分值加权)", value="weighted"), ], - default=chat_config['hybrid_fusion_strategy'], + default=chat_config["hybrid_fusion_strategy"], ).ask() if new_strategy: - chat_config['hybrid_fusion_strategy'] = new_strategy + chat_config["hybrid_fusion_strategy"] = new_strategy console.print(f"[green]混合融合策略已更新为: {new_strategy.upper()}[/green]") logger.info(f"混合融合策略已更新为: {new_strategy.upper()}") @@ -128,28 +141,31 @@ def is_float_between_0_and_1(text): default=str(current_candidate_multiplier), ).ask() if new_multiplier: - chat_config['retrieval_candidate_multiplier'] = int(new_multiplier) + chat_config["retrieval_candidate_multiplier"] = int(new_multiplier) console.print(f"[green]候选过量招募倍率已更新为: {new_multiplier}[/green]") logger.info(f"候选过量招募倍率已更新为: {new_multiplier}") # 检索参数的更改不需要重载任何模型 + def edit_model_params(chat_config: dict[str, Any]) -> bool: """编辑模型相关参数。""" logger.info("进入模型参数编辑菜单。") llm_changed = False while True: - current_llm = chat_config['active_llm_configuration'] - current_rerank = chat_config['active_rerank_configuration'] + current_llm = chat_config["active_llm_configuration"] + current_rerank = chat_config["active_rerank_configuration"] choice = questionary.select( "选择要切换的模型:", choices=[ questionary.Choice(f"1. 语言模型 (LLM) (当前: {current_llm})", value="llm"), - questionary.Choice(f"2. 重排模型 (Rerank) (当前: {current_rerank})", value="rerank"), + questionary.Choice( + f"2. 重排模型 (Rerank) (当前: {current_rerank})", value="rerank" + ), questionary.Separator(), - questionary.Choice("返回主菜单", value="back") + questionary.Choice("返回主菜单", value="back"), ], - style=questionary.Style([('pointer', 'bold fg:yellow')]), + style=questionary.Style([("pointer", "bold fg:yellow")]), ).ask() if choice == "back" or choice is None: @@ -157,41 +173,40 @@ def edit_model_params(chat_config: dict[str, Any]) -> bool: break if choice == "llm": - llm_options = list(chat_config['llm_configurations'].keys()) + llm_options = list(chat_config["llm_configurations"].keys()) new_llm = questionary.select( - "选择新的LLM配置:", - choices=llm_options, - default=current_llm + "选择新的LLM配置:", choices=llm_options, default=current_llm ).ask() if new_llm and new_llm != current_llm: - chat_config['active_llm_configuration'] = new_llm + chat_config["active_llm_configuration"] = new_llm console.print(f"[green]LLM配置已切换为: {new_llm}[/green]") logger.info(f"LLM配置已切换为: {new_llm}") llm_changed = True elif choice == "rerank": - rerank_options = list(chat_config['rerank_configurations'].keys()) + rerank_options = list(chat_config["rerank_configurations"].keys()) new_rerank = questionary.select( - "选择新的Rerank配置:", - choices=rerank_options, - default=current_rerank + "选择新的Rerank配置:", choices=rerank_options, default=current_rerank ).ask() if new_rerank: - chat_config['active_rerank_configuration'] = new_rerank + chat_config["active_rerank_configuration"] = new_rerank console.print(f"[green]Rerank已切换为: {new_rerank}[/green]") logger.info(f"Rerank已切换为: {new_rerank}") # Rerank模型是按需加载的,所以不需要标记状态 return llm_changed + def launch_config_editor(chat_config: dict[str, Any]) -> tuple[bool, dict[str, Any]]: """ 启动交互式配置编辑器。 返回一个元组 (llm_needs_reload, updated_config) """ logger.info("启动配置编辑器。") - console.print(Panel("进入配置模式...", title="[yellow]配置编辑器[/yellow]", border_style="yellow")) - + console.print( + Panel("进入配置模式...", title="[yellow]配置编辑器[/yellow]", border_style="yellow") + ) + llm_needs_reload = False while True: @@ -202,16 +217,22 @@ def launch_config_editor(chat_config: dict[str, Any]) -> tuple[bool, dict[str, A "2. 切换模型", "3. 查看当前完整配置", questionary.Separator(), - questionary.Choice("4. 保存并返回聊天", value="exit") + questionary.Choice("4. 保存并返回聊天", value="exit"), ], - style=questionary.Style([('pointer', 'bold fg:cyan')]), + style=questionary.Style([("pointer", "bold fg:cyan")]), ).ask() if choice == "exit" or choice is None: - console.print(Panel("配置完成,返回聊天。", title="[yellow]配置编辑器[/yellow]", border_style="yellow")) + console.print( + Panel( + "配置完成,返回聊天。", + title="[yellow]配置编辑器[/yellow]", + border_style="yellow", + ) + ) logger.info("配置编辑器退出,保存并返回聊天。") break - + if "1." in choice: logger.debug("用户选择调整检索参数。") # 检索参数的更改不会触发重载 diff --git a/src/ui/display_utils.py b/src/ui/display_utils.py index 21b5117..94cd978 100644 --- a/src/ui/display_utils.py +++ b/src/ui/display_utils.py @@ -8,11 +8,12 @@ from ..utils.config import ROOT_DIR, get_settings # 导入 get_settings 函数 from ..utils.log_manager import get_module_logger # 导入日志管理器 -logger = get_module_logger(__name__) # 获取当前模块的日志器 +logger = get_module_logger(__name__) # 获取当前模块的日志器 # 全局UI宽度定义 CONSOLE_WIDTH = 120 + def get_relative_path(absolute_path: str) -> str: """将绝对路径转换为相对于项目根目录的路径,方便显示。""" try: @@ -25,9 +26,11 @@ def get_relative_path(absolute_path: str) -> str: logger.warning(f"无法将路径 '{absolute_path}' 转换为相对路径,返回原始路径。") return str(absolute_path) + def display_chat_config(console: Console, chat_config: dict[str, Any]): """显示从传入的chat_config字典中获取的聊天机器人配置。""" logger.info("正在显示聊天机器人配置。") + def mask_api_key(key: str | None) -> str: if not key: logger.debug("API Key 未设置,显示为 '[dim]未设置[/dim]'。") @@ -36,39 +39,63 @@ def mask_api_key(key: str | None) -> str: return "[dim]已设置(值已隐藏)[/dim]" # --- 聊天配置 --- - chat_table = Table(title="[bold green]聊天机器人配置[/bold green]", show_header=False, box=None, padding=(0, 1)) + chat_table = Table( + title="[bold green]聊天机器人配置[/bold green]", show_header=False, box=None, padding=(0, 1) + ) chat_table.add_column(style="cyan") chat_table.add_column(style="bold white") - chat_table.add_row("检索策略:", chat_config['retrieval_method'].value) - chat_table.add_row("混合搜索权重 (向量/关键词):", f"{chat_config['vector_weight']} / {chat_config['keyword_weight']}") - chat_table.add_row("混合融合策略:", chat_config['hybrid_fusion_strategy'].upper()) - chat_table.add_row("候选过量招募倍率:", str(chat_config['retrieval_candidate_multiplier'])) - chat_table.add_row("Rerank重排:", "[bold green]启用[/bold green]" if chat_config['rerank_enabled'] else "[bold red]禁用[/bold red]") - if chat_config['rerank_enabled']: - active_rerank = chat_config['active_rerank_configuration'] - rerank_model_details = chat_config['rerank_configurations'][active_rerank] - chat_table.add_row("激活的Rerank模型:", f"{active_rerank} ({rerank_model_details.provider}: {rerank_model_details.model_name})") + chat_table.add_row("检索策略:", chat_config["retrieval_method"].value) + chat_table.add_row( + "混合搜索权重 (向量/关键词):", + f"{chat_config['vector_weight']} / {chat_config['keyword_weight']}", + ) + chat_table.add_row("混合融合策略:", chat_config["hybrid_fusion_strategy"].upper()) + chat_table.add_row("候选过量招募倍率:", str(chat_config["retrieval_candidate_multiplier"])) + chat_table.add_row( + "Rerank重排:", + "[bold green]启用[/bold green]" + if chat_config["rerank_enabled"] + else "[bold red]禁用[/bold red]", + ) + if chat_config["rerank_enabled"]: + active_rerank = chat_config["active_rerank_configuration"] + rerank_model_details = chat_config["rerank_configurations"][active_rerank] + chat_table.add_row( + "激活的Rerank模型:", + f"{active_rerank} ({rerank_model_details.provider}: {rerank_model_details.model_name})", + ) logger.debug(f"Rerank模型已启用,激活模型: {active_rerank}") else: logger.debug("Rerank模型未启用。") - chat_table.add_row("返回文档数 (top_k):", str(chat_config['top_k'])) - chat_table.add_row("语义搜索阈值:", str(chat_config['score_threshold'])) - active_llm = chat_config['active_llm_configuration'] - llm_model_details = chat_config['llm_configurations'][active_llm] - chat_table.add_row("激活的LLM:", f"{active_llm} ({llm_model_details.provider}: {llm_model_details.model_name})") + chat_table.add_row("返回文档数 (top_k):", str(chat_config["top_k"])) + chat_table.add_row("语义搜索阈值:", str(chat_config["score_threshold"])) + active_llm = chat_config["active_llm_configuration"] + llm_model_details = chat_config["llm_configurations"][active_llm] + chat_table.add_row( + "激活的LLM:", f"{active_llm} ({llm_model_details.provider}: {llm_model_details.model_name})" + ) logger.debug(f"激活的LLM: {active_llm}") # --- API与URL配置 --- # API密钥和URL仍然从全局的、不可变的settings对象中读取,因为它们不应该在运行时被修改。 - api_table = Table(title="[bold green]API密钥与URL配置[/bold green]", show_header=False, box=None, padding=(0, 1)) + api_table = Table( + title="[bold green]API密钥与URL配置[/bold green]", + show_header=False, + box=None, + padding=(0, 1), + ) api_table.add_column(style="cyan") api_table.add_column(style="bold white") # 仅显示与激活的LLM和Rerank相关的配置 active_providers = {llm_model_details.provider} - if chat_config['rerank_enabled']: - active_providers.add(chat_config['rerank_configurations'][chat_config['active_rerank_configuration']].provider) - - current_settings = get_settings() # 获取当前配置 + if chat_config["rerank_enabled"]: + active_providers.add( + chat_config["rerank_configurations"][ + chat_config["active_rerank_configuration"] + ].provider + ) + + current_settings = get_settings() # 获取当前配置 for provider_name in active_providers: key_name = f"{provider_name.lower()}_api_key" api_key = getattr(current_settings, key_name, None) @@ -77,15 +104,21 @@ def mask_api_key(key: str | None) -> str: logger.debug(f"显示提供商 '{provider_name}' 的 API Key。") else: logger.debug(f"提供商 '{provider_name}' 的 API Key 未设置。") - + url_key_name = f"{provider_name.lower()}_base_url" api_url = getattr(current_settings, url_key_name, None) if api_url: - api_table.add_row(f"{url_key_name.upper()}:", str(api_url) or "[dim]未设置[/dim]") - logger.debug(f"显示提供商 '{provider_name}' 的 Base URL: {api_url}") + api_table.add_row(f"{url_key_name.upper()}:", str(api_url) or "[dim]未设置[/dim]") + logger.debug(f"显示提供商 '{provider_name}' 的 Base URL: {api_url}") else: logger.debug(f"提供商 '{provider_name}' 的 Base URL 未设置。") - - console.print(Panel(Group(chat_table, api_table), title="[bold yellow]当前聊天会话配置[/bold yellow]", border_style="blue", width=CONSOLE_WIDTH)) + console.print( + Panel( + Group(chat_table, api_table), + title="[bold yellow]当前聊天会话配置[/bold yellow]", + border_style="blue", + width=CONSOLE_WIDTH, + ) + ) logger.info("聊天机器人配置显示完成。") diff --git a/src/utils/cleanup.py b/src/utils/cleanup.py index 60a27e3..ab83735 100644 --- a/src/utils/cleanup.py +++ b/src/utils/cleanup.py @@ -6,18 +6,19 @@ from .log_manager import get_module_logger # 导入日志管理器 from .security import redact_sensitive_text -logger = get_module_logger(__name__) # 获取当前模块的日志器 +logger = get_module_logger(__name__) # 获取当前模块的日志器 + def cleanup_temp_files(): """ 在程序退出时清理由本程序创建的 .cache 目录。 """ logger.info("执行退出前清理任务...") - + # 从 get_settings() 获取缓存路径 current_settings = get_settings() cache_dir = current_settings.cache_path - + if os.path.exists(cache_dir): try: shutil.rmtree(cache_dir) @@ -32,5 +33,6 @@ def cleanup_temp_files(): else: logger.info("未找到 .cache 目录,无需清理。") + # 注册函数,使其在程序正常退出时被调用 atexit.register(cleanup_temp_files) diff --git a/src/utils/config.py b/src/utils/config.py index 16068e8..c1324ae 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -21,6 +21,7 @@ # 1. 基础定义 (DEFINITIONS) # ================================================================= + # 项目根目录 def resolve_app_root() -> Path: """ @@ -36,10 +37,12 @@ def resolve_app_root() -> Path: ROOT_DIR = resolve_app_root() # 配置文件路径 -CONFIG_TOML_PATH = ROOT_DIR / 'config.toml' +CONFIG_TOML_PATH = ROOT_DIR / "config.toml" + class RetrievalMethod(str, Enum): """定义知识库检索的策略枚举。""" + SEMANTIC_SEARCH = "向量检索" FULL_TEXT_SEARCH = "全文检索" HYBRID_SEARCH = "混合检索" @@ -57,6 +60,7 @@ class ModelProtocol(str, Enum): class ModelDetail(BaseModel): """定义单个模型配置的结构。""" + provider: str model_name: str protocol: ModelProtocol | None = None @@ -78,11 +82,7 @@ def validate_options(cls, value: Any, info: ValidationInfo) -> dict[str, Any]: try: if provider == "google" and "http_options" in value: http_options = value["http_options"] - remaining = { - key: nested - for key, nested in value.items() - if key != "http_options" - } + remaining = {key: nested for key, nested in value.items() if key != "http_options"} validated = validate_secret_free_options(remaining, "模型") validated["http_options"] = validate_secret_free_options( {"http_options": http_options}, @@ -92,7 +92,12 @@ def validate_options(cls, value: Any, info: ValidationInfo) -> dict[str, Any]: return validated return validate_secret_free_options(value, "模型") except ValueError as exc: - raise ValueError(str(exc).replace("模型 options 不允许包含凭证或请求头/query 配置", "模型 options 不允许包含凭证或连接字段")) from exc + raise ValueError( + str(exc).replace( + "模型 options 不允许包含凭证或请求头/query 配置", + "模型 options 不允许包含凭证或连接字段", + ) + ) from exc @field_validator("protocol", mode="before") @classmethod @@ -123,15 +128,18 @@ def normalize_protocol(cls, value: Any) -> ModelProtocol | None: supported = ", ".join(protocol.value for protocol in ModelProtocol) raise ValueError(f"不支持的模型协议: {value}。可选值: {supported}") from exc + # ================================================================= # 2. 主配置模型 (MAIN SETTINGS MODEL) # ================================================================= + class Settings(BaseSettings): """ 定义整个应用的配置,使用Pydantic进行类型校验和分层加载。 加载顺序: 环境变量 > .env 文件 > config.toml 文件 > 模型中定义的默认值。 """ + # --- [API_KEYS] --- anthropic_api_key: str | None = Field(default=None, repr=False) google_api_key: str | None = Field(default=None, repr=False) @@ -154,11 +162,19 @@ class Settings(BaseSettings): _secret_fields: ClassVar[frozenset[str]] = frozenset( { - "anthropic_api_key", "google_api_key", "siliconflow_api_key", + "anthropic_api_key", + "google_api_key", + "siliconflow_api_key", "gemini_api_key", "google_application_credentials", - "openai_api_key", "qwen_api_key", "ark_api_key", "volc_access_key", - "volc_secret_key", "jina_api_key", "deepseek_api_key", "grok_api_key", + "openai_api_key", + "qwen_api_key", + "ark_api_key", + "volc_access_key", + "volc_secret_key", + "jina_api_key", + "deepseek_api_key", + "grok_api_key", "lm_studio_api_key", } ) @@ -198,8 +214,8 @@ def model_dump_json(self, *args: Any, **kwargs: Any) -> str: grok_base_url: str = "https://api.x.ai/v1" # --- [GENERAL] --- - log_level: str = "WARNING" # 新增 log_level 字段,默认级别调整为 WARNING - cache_path: str = ".cache" # 新增 cache_path 字段 + log_level: str = "WARNING" # 新增 log_level 字段,默认级别调整为 WARNING + cache_path: str = ".cache" # 新增 cache_path 字段 log_path: str = "data/logs" log_retention_days: int = 15 @@ -224,7 +240,7 @@ def model_dump_json(self, *args: Any, **kwargs: Any) -> str: default_llm_provider: str = "google" default_embedding_provider: str = "local-hash" default_rerank_provider: str = "siliconflow" - default_vector_store: str = "faiss" # 新增向量存储默认提供商 + default_vector_store: str = "faiss" # 新增向量存储默认提供商 # --- [CHAT] --- chat_retrieval_method: RetrievalMethod = RetrievalMethod.HYBRID_SEARCH @@ -235,33 +251,47 @@ def model_dump_json(self, *args: Any, **kwargs: Any) -> str: chat_rerank_enabled: bool = False chat_top_k: int = 5 chat_score_threshold: float = 0.4 - chat_temperature: float = 0.7 # 将 chat_temperature 移到这里 + chat_temperature: float = 0.7 # 将 chat_temperature 移到这里 # --- [MODEL_CONFIGURATIONS] --- - embedding_configurations: dict[str, ModelDetail] = Field(default_factory=lambda: { - # 与 llm_configurations 同理:兜底值必须写成当前有效的官方 ID。 - "local-hash": ModelDetail(provider="local-hash", model_name="local-hash-256"), - "google": ModelDetail(provider="google", model_name="gemini-embedding-2"), - "siliconflow": ModelDetail(provider="siliconflow", model_name="BAAI/bge-large-zh-v1.5"), - "openai": ModelDetail(provider="openai", model_name="text-embedding-3-small"), - }) - rerank_configurations: dict[str, ModelDetail] = Field(default_factory=lambda: { - "siliconflow": ModelDetail(provider="siliconflow", model_name="BAAI/bge-reranker-v2-m3"), - }) - llm_configurations: dict[str, ModelDetail] = Field(default_factory=lambda: { - # 默认条目只作为缺失配置时的兜底;模型名保持在写就时仍可用的现行 ID, - # 避免新用户照抄到已退役模型。 - "google": ModelDetail(provider="google", model_name="gemini-2.5-flash"), - "anthropic": ModelDetail(provider="anthropic", model_name="claude-sonnet-4-6"), - "qwen": ModelDetail(provider="qwen", model_name="qwen3.8-max"), - "deepseek": ModelDetail(provider="deepseek", model_name="deepseek-v4-pro"), - "grok": ModelDetail(provider="grok", model_name="grok-4.6"), - "volcengine": ModelDetail(provider="volcengine", model_name="doubao-seed-2-0-lite-260428"), - "siliconflow": ModelDetail(provider="siliconflow", model_name="deepseek-ai/DeepSeek-V3.2"), - "openai": ModelDetail(provider="openai", model_name="gpt-5.6-sol"), - "ollama": ModelDetail(provider="ollama", model_name="llama3.1"), - "lm-studio": ModelDetail(provider="lm-studio", model_name="LM-Studio-Community/Meta-Llama-3-8B-Instruct-GGUF"), - }) + embedding_configurations: dict[str, ModelDetail] = Field( + default_factory=lambda: { + # 与 llm_configurations 同理:兜底值必须写成当前有效的官方 ID。 + "local-hash": ModelDetail(provider="local-hash", model_name="local-hash-256"), + "google": ModelDetail(provider="google", model_name="gemini-embedding-2"), + "siliconflow": ModelDetail(provider="siliconflow", model_name="BAAI/bge-large-zh-v1.5"), + "openai": ModelDetail(provider="openai", model_name="text-embedding-3-small"), + } + ) + rerank_configurations: dict[str, ModelDetail] = Field( + default_factory=lambda: { + "siliconflow": ModelDetail( + provider="siliconflow", model_name="BAAI/bge-reranker-v2-m3" + ), + } + ) + llm_configurations: dict[str, ModelDetail] = Field( + default_factory=lambda: { + # 默认条目只作为缺失配置时的兜底;模型名保持在写就时仍可用的现行 ID, + # 避免新用户照抄到已退役模型。 + "google": ModelDetail(provider="google", model_name="gemini-2.5-flash"), + "anthropic": ModelDetail(provider="anthropic", model_name="claude-sonnet-4-6"), + "qwen": ModelDetail(provider="qwen", model_name="qwen3.8-max"), + "deepseek": ModelDetail(provider="deepseek", model_name="deepseek-v4-pro"), + "grok": ModelDetail(provider="grok", model_name="grok-4.6"), + "volcengine": ModelDetail( + provider="volcengine", model_name="doubao-seed-2-0-lite-260428" + ), + "siliconflow": ModelDetail( + provider="siliconflow", model_name="deepseek-ai/DeepSeek-V3.2" + ), + "openai": ModelDetail(provider="openai", model_name="gpt-5.6-sol"), + "ollama": ModelDetail(provider="ollama", model_name="llama3.1"), + "lm-studio": ModelDetail( + provider="lm-studio", model_name="LM-Studio-Community/Meta-Llama-3-8B-Instruct-GGUF" + ), + } + ) # --- [VALIDATORS] --- @field_validator("chat_top_k") @@ -278,7 +308,7 @@ def validate_chat_score_threshold(cls, value: float) -> float: raise ValueError("chat_score_threshold 必须在 0 到 1 之间。") return value - @field_validator('log_level', mode='before') + @field_validator("log_level", mode="before") @classmethod def validate_log_level(cls, v: str) -> str: """验证日志级别是否有效。""" @@ -295,9 +325,7 @@ def validate_non_llm_protocol( info: ValidationInfo, ) -> dict[str, ModelDetail]: """Embedding/Rerank 配置不允许携带 LLM 线协议。""" - invalid = sorted( - key for key, detail in value.items() if detail.protocol is not None - ) + invalid = sorted(key for key, detail in value.items() if detail.protocol is not None) if invalid: role = "Embedding" if info.field_name == "embedding_configurations" else "Rerank" raise ValueError( @@ -306,7 +334,7 @@ def validate_non_llm_protocol( ) return value - @field_validator('chat_temperature', mode='before') + @field_validator("chat_temperature", mode="before") @classmethod def validate_chat_temperature(cls, v: Any) -> float: """验证聊天温度在 0.0 到 1.0 之间。""" @@ -319,7 +347,7 @@ def validate_chat_temperature(cls, v: Any) -> float: raise ValueError(f"聊天温度必须在 0.0 到 1.0 之间,但得到 {value}。") return value - @field_validator('hybrid_fusion_strategy', mode='before') + @field_validator("hybrid_fusion_strategy", mode="before") @classmethod def validate_hybrid_fusion_strategy(cls, v: Any) -> str: """验证混合检索融合策略。""" @@ -329,7 +357,9 @@ def validate_hybrid_fusion_strategy(cls, v: Any) -> str: normalized = v.strip().lower() valid_strategies = {"rrf", "weighted"} if normalized not in valid_strategies: - raise ValueError(f"无效的混合检索融合策略: {v}. 必须是 {', '.join(sorted(valid_strategies))}。") + raise ValueError( + f"无效的混合检索融合策略: {v}. 必须是 {', '.join(sorted(valid_strategies))}。" + ) return normalized @model_validator(mode="after") @@ -367,14 +397,13 @@ def warn_retired_base_urls(self) -> "Settings": current = getattr(self, field_name, None) if isinstance(current, str) and current.rstrip("/") == old_url: warnings.warn( - f"{field_name} 指向旧端点 {old_url}({reason})," - f"请改为 {new_url}。", + f"{field_name} 指向旧端点 {old_url}({reason}),请改为 {new_url}。", UserWarning, stacklevel=2, ) return self - @field_validator('retrieval_candidate_multiplier', mode='before') + @field_validator("retrieval_candidate_multiplier", mode="before") @classmethod def validate_retrieval_candidate_multiplier(cls, v: Any) -> int: """验证检索候选过量招募倍率。""" @@ -387,7 +416,7 @@ def validate_retrieval_candidate_multiplier(cls, v: Any) -> int: raise ValueError(f"检索候选倍率必须大于等于 1,但得到 {value}。") return value - @field_validator('kb_splitter_separators', mode='before') + @field_validator("kb_splitter_separators", mode="before") @classmethod def split_separators(cls, v: Any) -> list[str]: """ @@ -404,19 +433,19 @@ def split_separators(cls, v: Any) -> list[str]: default_value = [] # 如果输入为空(来自 .env 或环境变量的空字符串),则回退到默认值 - if v is None or v == '': + if v is None or v == "": return default_value if isinstance(v, str): # 按逗号分割,并过滤掉空的元素 - separators = [s.strip() for s in v.split(',') if s.strip()] + separators = [s.strip() for s in v.split(",") if s.strip()] # 如果分割后列表为空(例如,输入是" , "),也使用默认值 return separators if separators else default_value - + # 如果输入已经是列表或其他类型,直接返回 return v - @field_validator('chat_retrieval_method', mode='before') + @field_validator("chat_retrieval_method", mode="before") @classmethod def validate_retrieval_method(cls, v: Any) -> Any: """允许使用枚举的键名(如HYBRID_SEARCH)或值(如'混合检索')进行配置。""" @@ -431,7 +460,9 @@ def validate_retrieval_method(cls, v: Any) -> Any: # 如果已经是枚举成员或无法转换,则让默认验证器处理 return v - @field_validator('knowledge_base_path', 'pkl_path', 'snapshot_root', 'log_path', 'cache_path', mode='before') + @field_validator( + "knowledge_base_path", "pkl_path", "snapshot_root", "log_path", "cache_path", mode="before" + ) @classmethod def resolve_path(cls, v: str) -> str: """将相对路径解析为绝对路径。""" @@ -461,13 +492,14 @@ def settings_customise_sources( ) model_config = SettingsConfigDict( - env_file='.env', - env_file_encoding='utf-8', + env_file=".env", + env_file_encoding="utf-8", case_sensitive=False, - extra='ignore', + extra="ignore", protected_namespaces=(), ) + def load_toml_config() -> dict[str, Any]: """ 从全局 CONFIG_TOML_PATH 路径加载 config.toml 文件配置。 @@ -583,13 +615,13 @@ def normalize_mapping(value: Any, preserve_keys: set[str] | None = None) -> Any: return flat_config + class TomlConfigSettingsSource(PydanticBaseSettingsSource): """ 一个 pydantic-settings 的自定义源,用于从 config.toml 文件加载配置。 """ - def get_field_value( - self, field: FieldInfo, field_name: str - ) -> tuple[Any, str, bool]: + + def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]: # 在 __call__ 中处理所有逻辑,这里可以什么都不做 return None, field_name, False @@ -601,10 +633,12 @@ def __call__(self) -> dict[str, Any]: """ return load_toml_config() + # ================================================================= # 3. 实例化并导出 (INSTANTIATE & EXPORT) # ================================================================= + @functools.lru_cache def get_settings() -> Settings: """ @@ -613,6 +647,7 @@ def get_settings() -> Settings: """ return Settings() + # 导出 get_settings 函数,供其他模块在需要时调用 # 这样可以确保在测试中能够灵活地替换或模拟配置 # settings = get_settings() # 移除直接导出 settings 实例 @@ -624,6 +659,7 @@ def get_settings() -> Settings: # 策略: 保持旧的配置变量,但使其从新的settings实例派生。 # 后续重构中,应逐步淘汰这些变量,直接使用 `get_settings()` 对象。 + def get_backward_compatible_configs() -> dict[str, Any]: """ 获取向后兼容的配置字典。 @@ -695,7 +731,7 @@ def get_backward_compatible_configs() -> dict[str, Any]: "VOLC_BASE_URL": str(current_settings.volc_base_url), "GROK_BASE_URL": str(current_settings.grok_base_url), } - + return { "KB_PATH": KB_PATH, "PKL_PATH": PKL_PATH, @@ -710,6 +746,7 @@ def get_backward_compatible_configs() -> dict[str, Any]: "LLM_CONFIGS": current_settings.llm_configurations, } + # --- 为了解决循环导入问题,将模型配置的导出移到最后 --- # 这些变量现在通过 get_backward_compatible_configs() 函数提供 # EMBEDDING_CONFIGS = settings.embedding_configurations diff --git a/src/utils/log_manager.py b/src/utils/log_manager.py index c28450b..d9bfc36 100644 --- a/src/utils/log_manager.py +++ b/src/utils/log_manager.py @@ -32,6 +32,7 @@ def format(self, record: logging.LogRecord) -> str: record.exc_text = redact_sensitive_text(record.exc_text) return redact_sensitive_text(formatted) + def get_chat_logger() -> logging.Logger: """ 获取并配置聊天日志记录器。 @@ -39,7 +40,7 @@ def get_chat_logger() -> logging.Logger: """ logger = logging.getLogger("chat_logger") current_settings = get_settings() - logger.setLevel(current_settings.log_level) # 从配置中获取日志级别 + logger.setLevel(current_settings.log_level) # 从配置中获取日志级别 # 避免重复添加处理器 if not logger.handlers: @@ -50,7 +51,7 @@ def get_chat_logger() -> logging.Logger: # 控制台处理器 console_handler = logging.StreamHandler() - console_formatter = RedactingFormatter('%(asctime)s - %(levelname)s - %(message)s') + console_formatter = RedactingFormatter("%(asctime)s - %(levelname)s - %(message)s") console_handler.setFormatter(console_formatter) logger.addHandler(console_handler) @@ -59,16 +60,17 @@ def get_chat_logger() -> logging.Logger: file_path = os.path.join(log_dir, log_file_name) file_handler = RotatingFileHandler( file_path, - maxBytes=1 * 1024 * 1024, # 1 MB + maxBytes=1 * 1024 * 1024, # 1 MB backupCount=5, - encoding='utf-8' + encoding="utf-8", ) - file_formatter = RedactingFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + file_formatter = RedactingFormatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") file_handler.setFormatter(file_formatter) logger.addHandler(file_handler) return logger + def get_module_logger(name: str) -> logging.Logger: """ 获取并配置模块日志记录器。 @@ -76,7 +78,7 @@ def get_module_logger(name: str) -> logging.Logger: """ logger = logging.getLogger(name) current_settings = get_settings() - logger.setLevel(current_settings.log_level) # 从配置中获取日志级别 + logger.setLevel(current_settings.log_level) # 从配置中获取日志级别 # 避免重复添加处理器 if not logger.handlers: @@ -87,7 +89,9 @@ def get_module_logger(name: str) -> logging.Logger: # 控制台处理器 console_handler = logging.StreamHandler() - console_formatter = RedactingFormatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + console_formatter = RedactingFormatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) console_handler.setFormatter(console_formatter) logger.addHandler(console_handler) @@ -96,16 +100,19 @@ def get_module_logger(name: str) -> logging.Logger: file_path = os.path.join(log_dir, log_file_name) file_handler = RotatingFileHandler( file_path, - maxBytes=1 * 1024 * 1024, # 1 MB + maxBytes=1 * 1024 * 1024, # 1 MB backupCount=5, - encoding='utf-8' + encoding="utf-8", + ) + file_formatter = RedactingFormatter( + "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s" ) - file_formatter = RedactingFormatter('%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s') file_handler.setFormatter(file_formatter) logger.addHandler(file_handler) return logger + def cleanup_old_logs(): """ 根据配置的保留天数清理旧的日志文件。 @@ -113,36 +120,35 @@ def cleanup_old_logs(): current_settings = get_settings() log_dir = current_settings.log_path log_retention_days = current_settings.log_retention_days - + if not os.path.exists(log_dir): return now = datetime.now().astimezone() - + # 获取所有日志文件 log_files = [f for f in os.listdir(log_dir) if f.endswith(".log")] - + for filename in log_files: file_path = os.path.join(log_dir, filename) try: # 从文件名中解析日期,例如 "chat_log_2023-10-26.log" 或 "app_log_2023-10-26.log" - match = re.search(r'(\d{4}-\d{2}-\d{2})', filename) + match = re.search(r"(\d{4}-\d{2}-\d{2})", filename) if match: file_date_str = match.group(1) - file_date = datetime.strptime(file_date_str, '%Y-%m-%d').replace( - tzinfo=now.tzinfo - ) - + file_date = datetime.strptime(file_date_str, "%Y-%m-%d").replace(tzinfo=now.tzinfo) + if (now - file_date).days > log_retention_days: os.remove(file_path) get_module_logger(__name__).info(f"已删除旧日志文件: {filename}") else: # 如果文件名不符合日期模式,也记录一下,但不删除 - get_module_logger(__name__).warning(f"日志文件名不符合日期模式,跳过清理: {filename}") + get_module_logger(__name__).warning( + f"日志文件名不符合日期模式,跳过清理: {filename}" + ) except Exception: # noqa: BLE001 - cleanup continues independently per file - get_module_logger(__name__).exception( - "清理日志文件 %s 时出错", filename - ) + get_module_logger(__name__).exception("清理日志文件 %s 时出错", filename) + # 示例用法 (可选,用于测试) if __name__ == "__main__": @@ -153,7 +159,7 @@ def cleanup_old_logs(): module_logger = get_module_logger(__name__) module_logger.info("这是一条模块信息。") module_logger.error("这是一条模块错误!") - + # 测试日志清理 # 为了测试,可以临时修改 log_retention_days 为一个很小的值,并创建一些旧文件 # cleanup_old_logs() diff --git a/src/utils/security.py b/src/utils/security.py index 9d2d25f..ce6a0a8 100644 --- a/src/utils/security.py +++ b/src/utils/security.py @@ -147,9 +147,9 @@ _KEY_VALUE_TEXT_RE = re.compile( r"(?i)(? str: return _GOOGLE_API_KEY_RE.sub("[REDACTED]", redacted) - def safe_exception_text(exc: BaseException) -> str: """把异常转成可安全展示的文本:脱敏凭证,并限制长度。 @@ -277,9 +276,16 @@ def is_sensitive_option_key(key: Any) -> bool: if part ] if any( - word in { - "apikey", "accesstoken", "authtoken", "apitoken", "bearer", - "password", "cookie", "token", + word + in { + "apikey", + "accesstoken", + "authtoken", + "apitoken", + "bearer", + "password", + "cookie", + "token", } for word in words ): @@ -287,9 +293,19 @@ def is_sensitive_option_key(key: Any) -> bool: if any(word == "secret" for word in words): return True if any( - left in { - "api", "access", "auth", "client", "secret", "private", "ssh", - "signing", "encryption", "goog", "google", + left + in { + "api", + "access", + "auth", + "client", + "secret", + "private", + "ssh", + "signing", + "encryption", + "goog", + "google", } and right in {"key", "token", "secret", "credential"} for left, right in pairwise(words) @@ -375,8 +391,7 @@ def validate_secret_free_options( found = find_sensitive_option_paths(options, allowed_containers=allowed_containers) if found: raise ValueError( - f"{provider} options 不允许包含凭证或请求头/query 配置: " - + ", ".join(found) + f"{provider} options 不允许包含凭证或请求头/query 配置: " + ", ".join(found) ) # 不能只复制最外层:模型配置通常包含 ``extra_body``、嵌套路由或 # SDK 配置对象。递归复制可以防止调用方在 Provider 初始化后修改 @@ -409,8 +424,7 @@ def validate_secret_free_request_overrides( found = find_sensitive_option_paths(value) if found: raise ValueError( - f"{provider} 请求头/query 不允许包含凭证: " - f"{field_name}." + ", ".join(found) + f"{provider} 请求头/query 不允许包含凭证: {field_name}." + ", ".join(found) ) copies[field_name] = _copy_nested(dict(value)) return copies["extra_headers"], copies["extra_query"] @@ -429,10 +443,7 @@ def validate_secret_free_payload( raise ValueError(f"{provider} {field_name} 必须是对象。") found = find_sensitive_option_paths(value) if found: - raise ValueError( - f"{provider} {field_name} 不允许包含凭证或连接字段: " - + ", ".join(found) - ) + raise ValueError(f"{provider} {field_name} 不允许包含凭证或连接字段: " + ", ".join(found)) return _copy_nested(dict(value)) @@ -467,8 +478,7 @@ def validate_secret_free_resource_kwargs( ) if found: raise ValueError( - f"{provider} 资源参数不允许包含凭证或请求头/query 配置: " - + ", ".join(found) + f"{provider} 资源参数不允许包含凭证或请求头/query 配置: " + ", ".join(found) ) normalized_keys: dict[str, Any] = {} @@ -483,8 +493,7 @@ def validate_secret_free_resource_kwargs( normalized_keys[normalized] = key if duplicate_extension_keys: raise ValueError( - f"{provider} 资源参数包含重复的扩展字段: " - + ", ".join(duplicate_extension_keys) + f"{provider} 资源参数包含重复的扩展字段: " + ", ".join(duplicate_extension_keys) ) headers_key = normalized_keys.get("extraheaders") query_key = normalized_keys.get("extraquery") diff --git a/tests/chat/test_core.py b/tests/chat/test_core.py index 2e8d534..92440dd 100644 --- a/tests/chat/test_core.py +++ b/tests/chat/test_core.py @@ -349,8 +349,12 @@ def __init__(self, _console: Console): score_threshold=0.4, active_llm_configuration="old-model", active_rerank_configuration="siliconflow", - llm_configurations={"old-model": ModelDetail(provider="openai", model_name="gpt-4o")}, - rerank_configurations={"siliconflow": ModelDetail(provider="siliconflow", model_name="rerank")}, + llm_configurations={ + "old-model": ModelDetail(provider="openai", model_name="gpt-4o") + }, + rerank_configurations={ + "siliconflow": ModelDetail(provider="siliconflow", model_name="rerank") + }, chat_temperature=0.7, ) self.chat_service = object() diff --git a/tests/etl/test_pipeline.py b/tests/etl/test_pipeline.py index 398adc8..3e8a935 100644 --- a/tests/etl/test_pipeline.py +++ b/tests/etl/test_pipeline.py @@ -32,13 +32,15 @@ 测试应该能正确处理这些内容。 """ + @pytest.fixture(scope="module") def mock_markdown_document(): """提供一个模拟的 Markdown Document 对象用于测试""" mock_path = MagicMock(spec=Path) mock_path.name = "test.md" mock_path.suffix = ".md" - return Document(content=SAMPLE_MARKDOWN, metadata={'source': str(mock_path)}) + return Document(content=SAMPLE_MARKDOWN, metadata={"source": str(mock_path)}) + def test_full_etl_pipeline_for_markdown(mock_markdown_document): """ @@ -46,19 +48,19 @@ def test_full_etl_pipeline_for_markdown(mock_markdown_document): 这个测试验证了从文件类型判断、处理器选择到最终切分的整个流程。 """ # 备份并临时修改全局配置以适应测试场景 - current_settings = get_settings() # 获取当前配置 + current_settings = get_settings() # 获取当前配置 original_chunk_size = current_settings.kb_chunk_size original_chunk_overlap = current_settings.kb_chunk_overlap - original_separators = current_settings.kb_splitter_separators # 备份分隔符 - + original_separators = current_settings.kb_splitter_separators # 备份分隔符 + # 临时设置较小的块大小,确保文档被分割 current_settings.kb_chunk_size = 50 current_settings.kb_chunk_overlap = 10 - current_settings.kb_splitter_separators = ["\n\n", "\n", " ", ""] # 确保分隔符设置 + current_settings.kb_splitter_separators = ["\n\n", "\n", " ", ""] # 确保分隔符设置 try: # 从模拟的文件路径初始化 Pipeline - pipeline = Pipeline.from_file_path(Path(mock_markdown_document.metadata['source'])) + pipeline = Pipeline.from_file_path(Path(mock_markdown_document.metadata["source"])) # 强制设置 splitter 模式为 'char' 以支持旧测试逻辑 (50字符) pipeline.splitter = RecursiveTextSplitter(mode="char") @@ -68,14 +70,14 @@ def test_full_etl_pipeline_for_markdown(mock_markdown_document): # 断言结果 assert isinstance(processed_docs, list), "处理结果应该是一个列表" assert len(processed_docs) > 1, "文档应该被切分成多个部分" - + # 验证清洗效果:不应再有多余的两个以上连续空格或三个以上连续换行符 for doc in processed_docs: - assert " " not in doc.content, "不应存在连续的两个空格" # 直接检查清洗后的内容 + assert " " not in doc.content, "不应存在连续的两个空格" # 直接检查清洗后的内容 assert "\n\n\n" not in doc.content, "不应存在连续的三个换行符" # 验证元数据是否被正确继承 - assert processed_docs[0].metadata['source'] == mock_markdown_document.metadata['source'] + assert processed_docs[0].metadata["source"] == mock_markdown_document.metadata["source"] # 验证切分内容 assert "Markdown 测试文档" in processed_docs[0].content @@ -85,7 +87,8 @@ def test_full_etl_pipeline_for_markdown(mock_markdown_document): # 恢复原始配置,避免影响其他测试 current_settings.kb_chunk_size = original_chunk_size current_settings.kb_chunk_overlap = original_chunk_overlap - current_settings.kb_splitter_separators = original_separators # 恢复分隔符 + current_settings.kb_splitter_separators = original_separators # 恢复分隔符 + def test_markdown_extractor(mock_markdown_document): """单独测试 MarkdownExtractor 的功能""" @@ -97,6 +100,7 @@ def test_markdown_extractor(mock_markdown_document): # Markdown 提取器应该保留原始内容 assert extracted_docs[0].content == SAMPLE_MARKDOWN + def test_basic_cleaner(): """单独测试 BasicCleaner 的文本清洗功能""" cleaner = BasicCleaner() @@ -109,18 +113,19 @@ def test_basic_cleaner(): # 验证多余空格、换行符和末尾空格是否被处理 assert cleaned_docs[0].content == "你好 世界 \n\n 再见." + def test_recursive_text_splitter(): """单独测试 RecursiveTextSplitter 的文本分割功能""" # 备份并临时修改全局配置 - current_settings = get_settings() # 获取当前配置 + current_settings = get_settings() # 获取当前配置 original_chunk_size = current_settings.kb_chunk_size original_chunk_overlap = current_settings.kb_chunk_overlap original_separators = current_settings.kb_splitter_separators - + # 直接在测试中设置适合分割的参数 test_chunk_size = 20 test_chunk_overlap = 5 - test_separators = ["\n\n", "\n", " "] # 明确分隔符 + test_separators = ["\n\n", "\n", " "] # 明确分隔符 try: # 实例化 RecursiveTextSplitter 时,它会从 settings 读取配置 @@ -128,12 +133,12 @@ def test_recursive_text_splitter(): current_settings.kb_chunk_overlap = test_chunk_overlap current_settings.kb_splitter_separators = test_separators - splitter = RecursiveTextSplitter(mode="char") # 实例化时强制用 char 模式 - + splitter = RecursiveTextSplitter(mode="char") # 实例化时强制用 char 模式 + # 使用一个更长的文本来测试分割和重叠 # 调整文本内容,使其在 chunk_size=20, chunk_overlap=5 的情况下能被分割成两部分 long_text = "这是一个非常长的句子,需要被正确地切分开来。\n\n这是第二部分。" - doc = Document(content=long_text, metadata={'source': 'test.txt'}) + doc = Document(content=long_text, metadata={"source": "test.txt"}) # split 方法期望 List[Document] 作为输入 split_docs = splitter.split([doc]) @@ -144,7 +149,7 @@ def test_recursive_text_splitter(): # 验证第二部分的内容 assert split_docs[1].content == "这是第二部分。" # 验证元数据 - assert split_docs[0].metadata['source'] == 'test.txt' + assert split_docs[0].metadata["source"] == "test.txt" finally: # 恢复原始配置 diff --git a/tests/providers/test_factory.py b/tests/providers/test_factory.py index 3daba5e..1b96133 100644 --- a/tests/providers/test_factory.py +++ b/tests/providers/test_factory.py @@ -21,20 +21,35 @@ class MockLLMProvider(LargeLanguageModel): def __init__(self, model_name: str): self.model_name = model_name - def invoke(self, prompt: str, system_prompt: str | None = "You are a helpful assistant.", tools: list[dict[str, Any]] | None = None, stream: bool = True, temperature: float = 0.7) -> Generator[str, None, None]: + def invoke( + self, + prompt: str, + system_prompt: str | None = "You are a helpful assistant.", + tools: list[dict[str, Any]] | None = None, + stream: bool = True, + temperature: float = 0.7, + ) -> Generator[str, None, None]: """模拟 LLM 聊天响应""" if stream: yield f"Mock LLM stream response for {self.model_name}" else: yield f"Mock LLM response for {self.model_name}" - async def ainvoke(self, prompt: str, system_prompt: str | None = "You are a helpful assistant.", tools: list[dict[str, Any]] | None = None, stream: bool = True, temperature: float = 0.7) -> AsyncGenerator[str, None]: + async def ainvoke( + self, + prompt: str, + system_prompt: str | None = "You are a helpful assistant.", + tools: list[dict[str, Any]] | None = None, + stream: bool = True, + temperature: float = 0.7, + ) -> AsyncGenerator[str, None]: """模拟异步 LLM 聊天响应""" if stream: yield f"Mock Async LLM stream response for {self.model_name}" else: yield f"Mock Async LLM response for {self.model_name}" + class MockEmbeddingProvider(TextEmbeddingModel): def __init__(self, model_name: str): self.model_name = model_name @@ -51,6 +66,7 @@ async def aembed_documents(self, texts: list[str]) -> list[list[float]]: """模拟异步文档嵌入""" return [[0.1] * 10 for _ in texts] + class MockRerankProvider(RerankModel): def __init__(self, model_name: str): self.model_name = model_name @@ -61,19 +77,26 @@ def rerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int scores = [1.0 - i * 0.1 for i in range(len(documents))] return indices[:top_n], scores[:top_n] - async def arerank(self, query: str, documents: list[str], top_n: int) -> tuple[list[int], list[float]]: + async def arerank( + self, query: str, documents: list[str], top_n: int + ) -> tuple[list[int], list[float]]: """模拟异步重排""" indices = list(range(len(documents))) scores = [1.0 - i * 0.1 for i in range(len(documents))] return indices[:top_n], scores[:top_n] + @pytest.fixture(scope="module") def mock_settings(): """模拟全局设置对象""" mock_llm_config = ModelDetail(provider="mock_llm", model_name="mock-llm-model") - mock_embedding_config = ModelDetail(provider="mock_embedding", model_name="mock-embedding-model") + mock_embedding_config = ModelDetail( + provider="mock_embedding", model_name="mock-embedding-model" + ) mock_rerank_config = ModelDetail(provider="mock_rerank", model_name="mock-rerank-model") - mock_siliconflow_rerank_config = ModelDetail(provider="siliconflow", model_name="mock-siliconflow-rerank-model") + mock_siliconflow_rerank_config = ModelDetail( + provider="siliconflow", model_name="mock-siliconflow-rerank-model" + ) mock_qwen_rerank_config = ModelDetail(provider="qwen", model_name="qwen-rerank-model") mock_settings_instance = MagicMock(spec=Settings) @@ -90,26 +113,27 @@ def mock_settings(): return mock_settings_instance + @pytest.fixture(scope="function", autouse=True) def patch_settings(mock_settings, monkeypatch): """在测试期间替换全局 get_settings 函数,使其返回模拟的 settings 对象,并修改 _provider_map""" - with patch('src.utils.config.get_settings', return_value=mock_settings): + with patch("src.utils.config.get_settings", return_value=mock_settings): # 清除 ModelProviderFactory 及其依赖模块的缓存 # 这确保了 ModelProviderFactory 在每次测试时都使用最新的模拟配置 modules_to_clear = [ - 'src.providers.factory', - 'src.providers.google', - 'src.providers.openai', - 'src.providers.anthropic', - 'src.providers.qwen', - 'src.providers.volcengine', - 'src.providers.siliconflow', - 'src.providers.ollama', - 'src.providers.lm_studio', - 'src.providers.deepseek', - 'src.providers.grok', - 'src.providers.jina', - 'src.providers.siliconflow_rerank', + "src.providers.factory", + "src.providers.google", + "src.providers.openai", + "src.providers.anthropic", + "src.providers.qwen", + "src.providers.volcengine", + "src.providers.siliconflow", + "src.providers.ollama", + "src.providers.lm_studio", + "src.providers.deepseek", + "src.providers.grok", + "src.providers.jina", + "src.providers.siliconflow_rerank", ] for module_name in modules_to_clear: if module_name in sys.modules: @@ -119,7 +143,7 @@ def patch_settings(mock_settings, monkeypatch): from src.providers.factory import ( ModelProviderFactory as ReloadedModelProviderFactory, ) - + # 直接模拟 _get_provider_class 方法 def mock_get_provider_class(provider_name: str): if provider_name == "mock_llm": @@ -133,13 +157,16 @@ def mock_get_provider_class(provider_name: str): else: raise ValueError(f"不支持的模型提供商: {provider_name}") - monkeypatch.setattr(ReloadedModelProviderFactory, "_get_provider_class", mock_get_provider_class) + monkeypatch.setattr( + ReloadedModelProviderFactory, "_get_provider_class", mock_get_provider_class + ) # 将重新导入的 ModelProviderFactory 赋值给全局 ModelProviderFactory,以便测试函数使用 global ModelProviderFactory ModelProviderFactory = ReloadedModelProviderFactory yield + # 测试用例 def test_get_llm_provider_success(): """测试成功获取 LLM 提供商""" @@ -147,6 +174,7 @@ def test_get_llm_provider_success(): assert isinstance(llm_provider, MockLLMProvider) assert llm_provider.model_name == "mock-llm-model" + def test_get_llm_provider_not_found(): """测试获取不存在的 LLM 提供商时抛出 ValueError""" with pytest.raises(ValueError, match="在LLM配置中未找到key: non_existent_key"): @@ -196,12 +224,14 @@ def test_factory_rejects_options_for_provider_without_options_parameter(): options={"dimensions": 2}, ) + def test_get_embedding_provider_success(): """测试成功获取 Embedding 提供商""" embedding_provider = ModelProviderFactory.get_embedding_provider("default_embedding") assert isinstance(embedding_provider, MockEmbeddingProvider) assert embedding_provider.model_name == "mock-embedding-model" + def test_get_embedding_provider_not_found(): """测试获取不存在的 Embedding 提供商时抛出 ValueError""" with pytest.raises(ValueError, match="在Embedding配置中未找到key: non_existent_key"): @@ -236,18 +266,21 @@ def test_get_rerank_provider_rejects_protocol_configuration(): with pytest.raises(ValueError, match="Rerank 配置不支持 protocol"): ModelProviderFactory.get_rerank_provider("rerank", configurations) + def test_get_rerank_provider_success(): """测试成功获取 Rerank 提供商""" rerank_provider = ModelProviderFactory.get_rerank_provider("default_rerank") assert isinstance(rerank_provider, MockRerankProvider) assert rerank_provider.model_name == "mock-rerank-model" + def test_get_rerank_provider_siliconflow_mapping(): """测试 Siliconflow Rerank 提供商的特殊映射""" rerank_provider = ModelProviderFactory.get_rerank_provider("siliconflow_rerank_key") assert isinstance(rerank_provider, MockRerankProvider) assert rerank_provider.model_name == "mock-siliconflow-rerank-model" + def test_get_rerank_provider_not_found(): """测试获取不存在的 Rerank 提供商时抛出 ValueError""" with pytest.raises(ValueError, match="在Rerank配置中未找到key: non_existent_key"): @@ -258,6 +291,7 @@ def test_get_rerank_provider_rejects_provider_without_rerank_capability(): with pytest.raises(TypeError, match="提供商 qwen 不支持 rerank 能力"): ModelProviderFactory.get_rerank_provider("qwen_rerank_key") + def test_unsupported_provider_type(): """测试 _get_provider_class 方法处理不支持的提供商名称""" with pytest.raises(ValueError, match="不支持的模型提供商: unsupported_provider"): diff --git a/tests/providers/test_failure_contracts.py b/tests/providers/test_failure_contracts.py index a814ec5..bca8496 100644 --- a/tests/providers/test_failure_contracts.py +++ b/tests/providers/test_failure_contracts.py @@ -122,8 +122,11 @@ async def consume() -> None: with pytest.raises(RuntimeError, match="boom-embed"): asyncio.run(provider.aembed_documents(["doc"])) + def test_openai_compatible_provider_async_failure_raises(monkeypatch): - fake_settings = SimpleNamespace(deepseek_api_key="token", deepseek_base_url="http://example.com") + fake_settings = SimpleNamespace( + deepseek_api_key="token", deepseek_base_url="http://example.com" + ) monkeypatch.setattr("src.providers.openai_compatible.get_settings", lambda: fake_settings) provider = OpenAICompatibleProvider("demo", "deepseek") @@ -139,6 +142,7 @@ async def consume() -> None: with pytest.raises(RuntimeError, match="boom-embed"): asyncio.run(provider.aembed_documents(["doc"])) + def test_openai_provider_reuses_compatible_adapter(monkeypatch): fake_settings = SimpleNamespace(openai_api_key="token", openai_api_base="http://example.com") monkeypatch.setattr("src.providers.openai.get_settings", lambda: fake_settings) @@ -173,7 +177,9 @@ def test_openai_compatible_provider_normalizes_hyphenated_settings(monkeypatch): def test_openai_compatible_provider_forwards_tools(monkeypatch): - fake_settings = SimpleNamespace(deepseek_api_key="token", deepseek_base_url="http://example.com") + fake_settings = SimpleNamespace( + deepseek_api_key="token", deepseek_base_url="http://example.com" + ) monkeypatch.setattr("src.providers.openai_compatible.get_settings", lambda: fake_settings) provider = OpenAICompatibleProvider("demo", "deepseek") diff --git a/tests/providers/test_google_genai.py b/tests/providers/test_google_genai.py index bf27544..660d953 100644 --- a/tests/providers/test_google_genai.py +++ b/tests/providers/test_google_genai.py @@ -17,6 +17,7 @@ def mock_settings(monkeypatch): monkeypatch.setenv("GOOGLE_API_KEY", "fake_key") return mock_settings_instance + @pytest.fixture def mock_genai_client(): with patch.object(google_module.genai, "Client") as mock_client_class: @@ -24,17 +25,20 @@ def mock_genai_client(): mock_client_class.return_value = mock_client yield mock_client + def test_google_provider_init(mock_settings): provider = GoogleProvider(model_name="gemini-1.5-flash") assert provider._model_name == "gemini-1.5-flash" assert provider._client is None + def test_google_provider_get_client(mock_settings, mock_genai_client): provider = GoogleProvider(model_name="gemini-1.5-flash") client = provider._get_client() assert client == mock_genai_client # Verify Client initialization from src.providers.google import genai + genai.Client.assert_called_once_with(api_key="fake_key") @@ -67,9 +71,7 @@ def test_google_provider_accepts_gemini_api_key_alias(monkeypatch): def test_google_api_key_takes_precedence_over_gemini_alias(monkeypatch): - settings = SimpleNamespace( - google_api_key="google-key", gemini_api_key="gemini-key" - ) + settings = SimpleNamespace(google_api_key="google-key", gemini_api_key="gemini-key") monkeypatch.setattr(google_module, "get_settings", lambda: settings) with patch.object(google_module.genai, "Client") as client_class: client_class.return_value = MagicMock() @@ -104,8 +106,9 @@ def test_google_credentials_reject_explicit_developer_api_mode(monkeypatch): model_name="gemini-1.5-flash", options={"credentials": credentials, "vertexai": False}, ) - with patch.object(google_module.genai, "Client"), pytest.raises( - ValueError, match="Vertex/Enterprise" + with ( + patch.object(google_module.genai, "Client"), + pytest.raises(ValueError, match="Vertex/Enterprise"), ): provider._get_client() @@ -118,9 +121,7 @@ def test_google_vertex_uses_adc_without_api_key(monkeypatch): monkeypatch.delenv("GEMINI_API_KEY", raising=False) with patch.object(google_module.genai, "Client") as client_class: client_class.return_value = MagicMock() - provider = GoogleProvider( - model_name="gemini-1.5-flash", options={"vertexai": True} - ) + provider = GoogleProvider(model_name="gemini-1.5-flash", options={"vertexai": True}) provider._get_client() @@ -165,8 +166,9 @@ def test_google_get_client_requires_explicit_auth_without_key_or_vertex_mode(mon monkeypatch.delenv("GEMINI_API_KEY", raising=False) provider = GoogleProvider(model_name="gemini-1.5-flash", options={}) - with patch.object(google_module.genai, "Client") as client_class, pytest.raises( - ValueError, match="GOOGLE_API_KEY|Vertex/Enterprise" + with ( + patch.object(google_module.genai, "Client") as client_class, + pytest.raises(ValueError, match="GOOGLE_API_KEY|Vertex/Enterprise"), ): provider._get_client() @@ -253,8 +255,9 @@ def test_google_invoke_non_stream_refusal_is_explicit_error(): client = MagicMock() client.models.generate_content.return_value = response - with patch.object(provider, "_get_client", return_value=client), pytest.raises( - RuntimeError, match="安全策略|拒答" + with ( + patch.object(provider, "_get_client", return_value=client), + pytest.raises(RuntimeError, match="安全策略|拒答"), ): list(provider.invoke("hello", stream=False)) @@ -272,8 +275,9 @@ def test_google_invoke_stream_refusal_is_explicit_error(): client = MagicMock() client.models.generate_content_stream.return_value = iter([chunk]) - with patch.object(provider, "_get_client", return_value=client), pytest.raises( - RuntimeError, match="安全策略|拒答" + with ( + patch.object(provider, "_get_client", return_value=client), + pytest.raises(RuntimeError, match="安全策略|拒答"), ): list(provider.invoke("hello", stream=True)) @@ -430,37 +434,39 @@ def __bool__(self): assert provider._get_client() is client client_class.assert_not_called() + def test_google_provider_invoke_non_stream(mock_settings, mock_genai_client): provider = GoogleProvider(model_name="gemini-1.5-flash") - + # 直接模拟 _get_client 以规避 tenacity 装饰器可能带来的环境隔离问题 - with patch.object(GoogleProvider, '_get_client', return_value=mock_genai_client): + with patch.object(GoogleProvider, "_get_client", return_value=mock_genai_client): mock_response = MagicMock() mock_response.text = "Hello world" mock_genai_client.models.generate_content.return_value = mock_response - + result = list(provider.invoke("test prompt", stream=False)) - + assert result == ["Hello world"] mock_genai_client.models.generate_content.assert_called_once() + def test_google_provider_embed_documents(mock_settings, mock_genai_client): provider = GoogleProvider(model_name="embedding-001") - + # 直接模拟 _get_client - with patch.object(GoogleProvider, '_get_client', return_value=mock_genai_client): + with patch.object(GoogleProvider, "_get_client", return_value=mock_genai_client): mock_emb_1 = MagicMock() mock_emb_1.values = [0.1, 0.2] mock_emb_2 = MagicMock() mock_emb_2.values = [0.3, 0.4] - + mock_response = MagicMock() mock_response.embeddings = [mock_emb_1, mock_emb_2] mock_genai_client.models.embed_content.return_value = mock_response - + texts = ["text1", "text2"] result = provider.embed_documents(texts) - + assert result == [[0.1, 0.2], [0.3, 0.4]] mock_genai_client.models.embed_content.assert_called_once() @@ -469,7 +475,9 @@ def test_google_embedding_rejects_request_credentials_before_client_init(mock_se provider = GoogleProvider(model_name="embedding-001") with ( - patch.object(provider, "_get_client", side_effect=AssertionError("client must not initialize")), + patch.object( + provider, "_get_client", side_effect=AssertionError("client must not initialize") + ), pytest.raises(ValueError, match="凭证"), ): provider.embed_documents( @@ -479,7 +487,9 @@ def test_google_embedding_rejects_request_credentials_before_client_init(mock_se async def consume() -> None: with ( - patch.object(provider, "_get_client", side_effect=AssertionError("client must not initialize")), + patch.object( + provider, "_get_client", side_effect=AssertionError("client must not initialize") + ), pytest.raises(ValueError, match="凭证"), ): await provider.aembed_documents( diff --git a/tests/providers/test_protocol_adapters.py b/tests/providers/test_protocol_adapters.py index 15600e1..001b6fd 100644 --- a/tests/providers/test_protocol_adapters.py +++ b/tests/providers/test_protocol_adapters.py @@ -39,9 +39,7 @@ def test_anthropic_converts_openai_tool_schema(): provider = object.__new__(AnthropicProvider) provider._model_name = "claude-test" - params = AnthropicProvider._build_message_params( - provider, "hello", "system", TOOLS, 0.2 - ) + params = AnthropicProvider._build_message_params(provider, "hello", "system", TOOLS, 0.2) assert params["tools"] == [ { @@ -67,9 +65,7 @@ def test_anthropic_parse_rejects_user_profile_on_non_beta_sdk_path(): def test_anthropic_rejects_unlinked_tool_result(): with pytest.raises(ValueError, match="tool_call_id"): - AnthropicProvider._convert_messages( - [{"role": "tool", "content": "{}"}] - ) + AnthropicProvider._convert_messages([{"role": "tool", "content": "{}"}]) def test_anthropic_rejects_malformed_tool_call_arguments(): @@ -91,9 +87,7 @@ def test_anthropic_rejects_malformed_tool_call_arguments(): def test_anthropic_rejects_image_content_without_url(): with pytest.raises(ValueError, match="图片内容缺少有效 image_url"): - AnthropicProvider._convert_content( - [{"type": "image_url", "image_url": {}}] - ) + AnthropicProvider._convert_content([{"type": "image_url", "image_url": {}}]) def test_anthropic_rejects_invalid_image_data_uri(): @@ -342,7 +336,9 @@ def test_google_vertex_accepts_cloud_storage_and_public_https_media(): ) assert contents[0].parts[0].file_data.file_uri == "gs://bucket/image.png" - assert contents[0].parts[1].file_data.file_uri == "https://cdn.example.com/document.pdf?version=1" + assert ( + contents[0].parts[1].file_data.file_uri == "https://cdn.example.com/document.pdf?version=1" + ) @pytest.mark.parametrize("file_id", ["", " ", "files/", "file/id"]) @@ -375,9 +371,7 @@ def test_google_rejects_malformed_tool_arguments_and_results(): with pytest.raises(ValueError, match="content.*有效 JSON"): GoogleProvider._contents( - [ - {"role": "tool", "tool_call_id": "call-1", "name": "lookup", "content": "{"} - ], + [{"role": "tool", "tool_call_id": "call-1", "name": "lookup", "content": "{"}], None, None, ) @@ -422,9 +416,7 @@ def test_google_decodes_media_data_uri_exactly_once(): [ { "role": "user", - "content": [ - {"type": "audio_url", "audio_url": "data:audio/wav;base64,YWJj"} - ], + "content": [{"type": "audio_url", "audio_url": "data:audio/wav;base64,YWJj"}], } ], None, @@ -460,9 +452,7 @@ def test_google_tool_call_message_with_null_content_does_not_emit_text(): def test_google_rejects_malformed_function_tool_choice(): with pytest.raises(ValueError, match="function.name"): - GoogleProvider._convert_tool_choice( - {"type": "function", "function": {}} - ) + GoogleProvider._convert_tool_choice({"type": "function", "function": {}}) def test_anthropic_merges_parallel_tool_results_into_one_user_turn(): @@ -795,7 +785,12 @@ def test_responses_input_rejects_unsupported_or_incomplete_content_blocks(conten [ [{"role": "tool", "content": "missing id"}], [{"role": "assistant", "tool_calls": [{"function": {"arguments": "{}"}}]}], - [{"role": "assistant", "tool_calls": [{"id": "call-1", "function": {"name": "lookup", "arguments": "{"}}]}], + [ + { + "role": "assistant", + "tool_calls": [{"id": "call-1", "function": {"name": "lookup", "arguments": "{"}}], + } + ], [{"type": "function_call", "id": "item-1", "name": "lookup", "arguments": "{}"}], ], ) @@ -902,9 +897,7 @@ def test_ark_responses_request_omits_implicit_temperature(): provider._model_name = "ark-model" provider._options = {"server_verified_protocols": ["responses"]} - request = provider._build_responses_request( - CompletionRequest(prompt="hello", stream=False) - ) + request = provider._build_responses_request(CompletionRequest(prompt="hello", stream=False)) assert "temperature" not in request @@ -917,9 +910,7 @@ def test_ark_responses_request_preserves_configured_temperature(): "temperature": 0.2, } - request = provider._build_responses_request( - CompletionRequest(prompt="hello", stream=False) - ) + request = provider._build_responses_request(CompletionRequest(prompt="hello", stream=False)) assert request["temperature"] == 0.2 @@ -1038,9 +1029,7 @@ def test_openai_responses_rejects_ark_only_multimodal_content(): [ { "role": "user", - "content": [ - {"type": "input_audio", "audio_url": "https://example.com/a.wav"} - ], + "content": [{"type": "input_audio", "audio_url": "https://example.com/a.wav"}], } ], ) @@ -1062,9 +1051,12 @@ def test_responses_request_converts_chat_tool_choice_to_flat_function_shape(): def test_responses_request_preserves_native_tool_choice_forms(): provider = _responses_provider() - assert provider._build_responses_request("hello", tool_choice="required", stream=False)[ - "tool_choice" - ] == "required" + assert ( + provider._build_responses_request("hello", tool_choice="required", stream=False)[ + "tool_choice" + ] + == "required" + ) assert provider._build_responses_request( "hello", tool_choice={"type": "allowed_tools", "mode": "auto"}, stream=False )["tool_choice"] == {"type": "allowed_tools", "mode": "auto"} @@ -1113,9 +1105,7 @@ def test_ark_responses_rejects_conflicting_model_length_aliases(): } with pytest.raises(ValueError, match="max_tokens.*max_completion_tokens"): - provider._build_responses_request( - CompletionRequest(prompt="hello", stream=False) - ) + provider._build_responses_request(CompletionRequest(prompt="hello", stream=False)) def test_ark_extra_body_sampling_conflicts_are_explicit(): @@ -1123,9 +1113,7 @@ def test_ark_extra_body_sampling_conflicts_are_explicit(): provider._model_name = "ark-model" provider._options = {"extra_body": {"seed": 1}} with pytest.raises(ValueError, match="seed.*重复"): - provider._build_responses_request( - CompletionRequest(prompt="hello", stream=False, seed=2) - ) + provider._build_responses_request(CompletionRequest(prompt="hello", stream=False, seed=2)) def test_responses_sync_non_stream_extracts_output_text(): @@ -1139,7 +1127,9 @@ def create(self, **request): provider._get_client = lambda: SimpleNamespace(responses=Responses()) - assert list(provider._invoke_responses({"model": "responses-model", "input": "hello", "stream": False})) == ["答复"] + assert list( + provider._invoke_responses({"model": "responses-model", "input": "hello", "stream": False}) + ) == ["答复"] assert calls == [{"model": "responses-model", "input": "hello", "stream": False}] @@ -1186,9 +1176,7 @@ def create(self, **request): provider._get_client = lambda: SimpleNamespace(responses=Responses()) - assert provider.complete( - CompletionRequest(messages=history, system_prompt=None) - ).text == "答复" + assert provider.complete(CompletionRequest(messages=history, system_prompt=None)).text == "答复" list(provider.stream_events(CompletionRequest(messages=history, system_prompt=None))) assert calls[0]["input"][2]["type"] == "function_call" @@ -1205,6 +1193,7 @@ class Responses: async def create(self, **request): calls.append(request) if request["stream"]: + class Stream: def __aiter__(self): self._events = iter( @@ -1229,9 +1218,7 @@ async def __anext__(self): provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) async def collect(): - result = await provider.acomplete( - CompletionRequest(messages=history, system_prompt=None) - ) + result = await provider.acomplete(CompletionRequest(messages=history, system_prompt=None)) events = [ event async for event in provider.astream_events( @@ -1288,12 +1275,7 @@ async def close(self): provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) async def collect(): - return [ - event - async for event in provider.astream_events( - CompletionRequest(prompt="hello") - ) - ] + return [event async for event in provider.astream_events(CompletionRequest(prompt="hello"))] events = asyncio.run(collect()) @@ -1326,9 +1308,7 @@ def create(self, **request): provider._get_client = lambda: SimpleNamespace(responses=Responses()) - assert provider.complete( - CompletionRequest(messages=history, system_prompt=None) - ).text == "答复" + assert provider.complete(CompletionRequest(messages=history, system_prompt=None)).text == "答复" list(provider.stream_events(CompletionRequest(messages=history, system_prompt=None))) assert calls[0]["input"][2]["type"] == "function_call" @@ -1347,6 +1327,7 @@ class Responses: async def create(self, **request): calls.append(request) if request["stream"]: + class Stream: def __aiter__(self): self._events = iter( @@ -1371,9 +1352,7 @@ async def __anext__(self): provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) async def collect(): - result = await provider.acomplete( - CompletionRequest(messages=history, system_prompt=None) - ) + result = await provider.acomplete(CompletionRequest(messages=history, system_prompt=None)) events = [ event async for event in provider.astream_events( @@ -1417,7 +1396,9 @@ def create(self, **_request): SimpleNamespace( id="call-1", index=0, - function=SimpleNamespace(name="lookup", arguments='{"id":'), + function=SimpleNamespace( + name="lookup", arguments='{"id":' + ), ) ], ), @@ -1434,7 +1415,7 @@ def create(self, **_request): SimpleNamespace( id=None, index=0, - function=SimpleNamespace(name=None, arguments='1}'), + function=SimpleNamespace(name=None, arguments="1}"), ) ], ), @@ -1443,7 +1424,11 @@ def create(self, **_request): ] ), SimpleNamespace( - choices=[SimpleNamespace(delta=SimpleNamespace(content=None), finish_reason="tool_calls")], + choices=[ + SimpleNamespace( + delta=SimpleNamespace(content=None), finish_reason="tool_calls" + ) + ], usage=SimpleNamespace(prompt_tokens=2, completion_tokens=3, total_tokens=5), ), ] @@ -1453,7 +1438,11 @@ def create(self, **_request): events = list(provider.stream_events(CompletionRequest(prompt="hello"))) assert [event.type for event in events] == [ - "text_delta", "tool_call_delta", "tool_call_delta", "tool_call_completed", "finish" + "text_delta", + "tool_call_delta", + "tool_call_delta", + "tool_call_completed", + "finish", ] assert events[1].tool_call["name"] == "lookup" assert events[3].tool_call["arguments"] == '{"id":1}' @@ -1467,7 +1456,9 @@ def test_openai_chat_stream_events_preserve_text_and_finish_from_same_chunk(): id="chat-1", choices=[ SimpleNamespace( - delta=SimpleNamespace(content="答", reasoning_content=None, refusal=None, tool_calls=None), + delta=SimpleNamespace( + content="答", reasoning_content=None, refusal=None, tool_calls=None + ), finish_reason="stop", ) ], @@ -1552,7 +1543,9 @@ def __aiter__(self): [ SimpleNamespace( type="error", - error=SimpleNamespace(message="async chat upstream failed", status=400), + error=SimpleNamespace( + message="async chat upstream failed", status=400 + ), ) ] ) @@ -1566,9 +1559,7 @@ async def __anext__(self): return Stream() - provider._get_aclient = lambda: SimpleNamespace( - chat=SimpleNamespace(completions=Completions()) - ) + provider._get_aclient = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) async def collect(): return [event async for event in provider.astream_events(CompletionRequest(prompt="hello"))] @@ -1591,7 +1582,9 @@ def __aiter__(self): [ SimpleNamespace( type="error", - error=SimpleNamespace(message="async ark upstream failed", status=400), + error=SimpleNamespace( + message="async ark upstream failed", status=400 + ), ) ] ) @@ -1605,9 +1598,7 @@ async def __anext__(self): return Stream() - provider._get_aclient = lambda: SimpleNamespace( - chat=SimpleNamespace(completions=Completions()) - ) + provider._get_aclient = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) async def collect(): return [event async for event in provider.astream_events(CompletionRequest(prompt="hello"))] @@ -1658,11 +1649,13 @@ def test_openai_chat_stream_events_preserve_parallel_tool_calls(): content=None, tool_calls=[ SimpleNamespace( - id="call-1", index=0, + id="call-1", + index=0, function=SimpleNamespace(name="lookup", arguments='{"id":'), ), SimpleNamespace( - id="call-2", index=1, + id="call-2", + index=1, function=SimpleNamespace(name="refund", arguments='{"order":'), ), ], @@ -1680,12 +1673,15 @@ def test_openai_chat_stream_events_preserve_parallel_tool_calls(): def test_openai_responses_stream_exposes_tool_delta_and_completed_events(): - assert OpenAICompatibleProvider._responses_stream_event( - SimpleNamespace( - type="response.output_item.added", - item=SimpleNamespace(type="function_call", call_id="call-1", name="lookup"), + assert ( + OpenAICompatibleProvider._responses_stream_event( + SimpleNamespace( + type="response.output_item.added", + item=SimpleNamespace(type="function_call", call_id="call-1", name="lookup"), + ) ) - ) is None + is None + ) delta = OpenAICompatibleProvider._responses_stream_event( SimpleNamespace( @@ -2400,7 +2396,10 @@ def test_openai_responses_non_stream_reasoning_summary_list_is_preserved(): output=[ SimpleNamespace( type="reasoning", - summary=[SimpleNamespace(type="summary_text", text="先分析"), SimpleNamespace(text="后结论")], + summary=[ + SimpleNamespace(type="summary_text", text="先分析"), + SimpleNamespace(text="后结论"), + ], ) ] ) @@ -2510,9 +2509,7 @@ def test_chat_invoke_accepts_mapping_shaped_sdk_response(): provider._options = {} response = {"choices": [{"message": {"content": "字典答复"}}]} provider._get_client = lambda: SimpleNamespace( - chat=SimpleNamespace( - completions=SimpleNamespace(create=lambda **_kwargs: response) - ) + chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_kwargs: response)) ) assert list(provider.invoke("hello", stream=False)) == ["字典答复"] @@ -2530,9 +2527,7 @@ class Completions: async def create(self, **_kwargs): return response - provider._get_aclient = lambda: SimpleNamespace( - chat=SimpleNamespace(completions=Completions()) - ) + provider._get_aclient = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) async def consume(): return [chunk async for chunk in provider.ainvoke("hello", stream=False)] @@ -2548,9 +2543,7 @@ def test_openai_chat_business_error_is_explicit_for_complete_and_invoke(): provider._options = {} response = SimpleNamespace(status="435", msg="Model not support", choices=None) provider._get_client = lambda: SimpleNamespace( - chat=SimpleNamespace( - completions=SimpleNamespace(create=lambda **_kwargs: response) - ) + chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_kwargs: response)) ) with pytest.raises(RuntimeError, match="435.*Model not support"): @@ -2571,9 +2564,7 @@ class Completions: async def create(self, **_kwargs): return response - provider._get_aclient = lambda: SimpleNamespace( - chat=SimpleNamespace(completions=Completions()) - ) + provider._get_aclient = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) with pytest.raises(RuntimeError, match="435.*Model not support"): asyncio.run(provider.acomplete(CompletionRequest(prompt="hello"))) @@ -2593,9 +2584,7 @@ def test_openai_chat_business_error_is_explicit_for_sync_and_async_streams(): provider._options = {} response = SimpleNamespace(status="435", msg="Model not support", choices=None) provider._get_client = lambda: SimpleNamespace( - chat=SimpleNamespace( - completions=SimpleNamespace(create=lambda **_kwargs: iter([response])) - ) + chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_kwargs: iter([response]))) ) with pytest.raises(RuntimeError, match="435.*Model not support"): @@ -2844,9 +2833,7 @@ def test_openai_and_ark_embedding_base64_is_decoded_to_float_vectors(): openai_provider._client = None openai_provider._get_client = lambda: SimpleNamespace( embeddings=SimpleNamespace( - create=lambda **_kwargs: SimpleNamespace( - data=[SimpleNamespace(embedding=encoded)] - ) + create=lambda **_kwargs: SimpleNamespace(data=[SimpleNamespace(embedding=encoded)]) ) ) assert openai_provider.embed_documents(["text"], encoding_format="base64") == [[1.25, -2.5]] @@ -2856,9 +2843,7 @@ def test_openai_and_ark_embedding_base64_is_decoded_to_float_vectors(): ark_provider._options = {} ark_provider._get_client = lambda: SimpleNamespace( embeddings=SimpleNamespace( - create=lambda **_kwargs: SimpleNamespace( - data=[SimpleNamespace(embedding=encoded)] - ) + create=lambda **_kwargs: SimpleNamespace(data=[SimpleNamespace(embedding=encoded)]) ) ) assert ark_provider.embed_documents(["text"], encoding_format="base64") == [[1.25, -2.5]] @@ -2876,7 +2861,12 @@ async def create(self, **request): provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) async def collect(): - return [chunk async for chunk in provider._ainvoke_responses({"model": "responses-model", "input": "hello", "stream": False})] + return [ + chunk + async for chunk in provider._ainvoke_responses( + {"model": "responses-model", "input": "hello", "stream": False} + ) + ] assert asyncio.run(collect()) == ["异步答复"] assert calls == [{"model": "responses-model", "input": "hello", "stream": False}] @@ -2899,7 +2889,11 @@ def create(self, **request): provider._get_client = lambda: SimpleNamespace(responses=Responses()) with pytest.raises(RuntimeError, match="拒答"): - list(provider._invoke_responses({"model": "responses-model", "input": "hello", "stream": True})) + list( + provider._invoke_responses( + {"model": "responses-model", "input": "hello", "stream": True} + ) + ) def test_responses_sync_non_stream_refusal_is_explicit_error(): @@ -2920,7 +2914,11 @@ def create(self, **_request): provider._get_client = lambda: SimpleNamespace(responses=Responses()) with pytest.raises(RuntimeError, match="拒答"): - list(provider._invoke_responses({"model": "responses-model", "input": "hello", "stream": False})) + list( + provider._invoke_responses( + {"model": "responses-model", "input": "hello", "stream": False} + ) + ) def test_responses_async_stream_yields_text_deltas(): @@ -2952,7 +2950,12 @@ async def create(self, **request): provider._get_aclient = lambda: SimpleNamespace(responses=Responses()) async def collect(): - return [chunk async for chunk in provider._ainvoke_responses({"model": "responses-model", "input": "hello", "stream": True})] + return [ + chunk + async for chunk in provider._ainvoke_responses( + {"model": "responses-model", "input": "hello", "stream": True} + ) + ] assert asyncio.run(collect()) == ["异", "步"] @@ -3002,7 +3005,11 @@ def create(self, **request): provider._get_client = lambda: SimpleNamespace(responses=Responses()) with pytest.raises(RuntimeError, match="upstream failed"): - list(provider._invoke_responses({"model": "responses-model", "input": "hello", "stream": True})) + list( + provider._invoke_responses( + {"model": "responses-model", "input": "hello", "stream": True} + ) + ) def test_responses_stream_incomplete_event_is_explicit_error(): @@ -3025,14 +3032,16 @@ def create(self, **request): provider._get_client = lambda: SimpleNamespace(responses=Responses()) with pytest.raises(RuntimeError, match="content_filter"): - list(provider._invoke_responses({"model": "responses-model", "input": "hello", "stream": True})) + list( + provider._invoke_responses( + {"model": "responses-model", "input": "hello", "stream": True} + ) + ) def test_responses_stream_error_event_is_explicit_error(): with pytest.raises(RuntimeError, match="rate limit"): - OpenAICompatibleProvider._stream_delta( - SimpleNamespace(type="error", message="rate limit") - ) + OpenAICompatibleProvider._stream_delta(SimpleNamespace(type="error", message="rate limit")) def test_responses_cancelled_events_are_explicit_errors(): @@ -3045,9 +3054,7 @@ def test_responses_cancelled_events_are_explicit_errors(): ) with pytest.raises(RuntimeError, match="cancelled|取消"): - OpenAICompatibleProvider._stream_delta( - SimpleNamespace(type="response.cancelled") - ) + OpenAICompatibleProvider._stream_delta(SimpleNamespace(type="response.cancelled")) def test_openai_responses_non_stream_extracts_refusal_output(): diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index 7ba57f9..a2d188c 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -40,7 +40,11 @@ def test_model_detail_options_are_explicit_and_secret_free(): with pytest.raises(ValueError, match="凭证"): ModelDetail(provider="openai", model_name="demo", options={"api_key": "secret"}) with pytest.raises(ValueError, match="凭证"): - ModelDetail(provider="openai", model_name="demo", options={"nested": {"headers": {"Authorization": "x"}}}) + ModelDetail( + provider="openai", + model_name="demo", + options={"nested": {"headers": {"Authorization": "x"}}}, + ) with pytest.raises(ValueError): ModelDetail(provider="openai", model_name="demo", unknown=True) @@ -584,7 +588,10 @@ async def run() -> None: cases = ( (provider.async_create_vector_store, {"extra_headers": {"X-API-Key": "secret"}}), (provider.async_create_upload, {"extra_query": {"access_token": "secret"}}), - (provider.async_complete_upload, {"upload_id": "upload-1", "extra_body": {"token": "secret"}}), + ( + provider.async_complete_upload, + {"upload_id": "upload-1", "extra_body": {"token": "secret"}}, + ), ) for operation, kwargs in cases: with pytest.raises(ValueError, match="凭证"): @@ -649,7 +656,11 @@ def test_openai_async_resource_groups_validate_unknown_arguments_before_dispatch async def run() -> None: cases = ( - (provider.async_upload_file, (b"data",), {"purpose": "assistants", "unknown_option": True}), + ( + provider.async_upload_file, + (b"data",), + {"purpose": "assistants", "unknown_option": True}, + ), (provider.async_create_batch, ("file-1",), {"unknown_option": True}), (provider.async_create_fine_tuning_job, (), {"unknown_option": True}), ) @@ -730,7 +741,10 @@ def test_openai_responses_extra_body_cannot_override_request_fields(): provider._model_name = "demo" provider._provider = "qwen" provider._protocol = "responses" - provider._options = {"server_verified_protocols": ["responses"], "extra_body": {"input": "other"}} + provider._options = { + "server_verified_protocols": ["responses"], + "extra_body": {"input": "other"}, + } with pytest.raises(ValueError, match="extra_body.*input"): provider._build_responses_request(prompt="hi", stream=False) @@ -838,6 +852,7 @@ def test_openai_embedding_extra_body_conflicts_are_explicit(async_mode): provider._aclient = SimpleNamespace(embeddings=SimpleNamespace(create=lambda **_: None)) if async_mode: + async def run(): await provider.aembed_documents(["text"], extra_body={"tenant": "request"}) @@ -869,6 +884,7 @@ def test_openai_embedding_request_overrides_reject_credentials(async_mode, field kwargs = {field_name: value} if async_mode: + async def run(): await provider.aembed_documents(["text"], **kwargs) @@ -932,16 +948,26 @@ def test_openai_complete_preserves_chat_text_tool_calls_and_usage(): provider._protocol = "chat_completions" provider._options = {} response = SimpleNamespace( - choices=[SimpleNamespace( - message=SimpleNamespace( - content="answer", - tool_calls=[SimpleNamespace(id="call-1", type="function", function=SimpleNamespace(name="lookup", arguments='{"id":1}'))], - ), - finish_reason="tool_calls", - )], + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content="answer", + tool_calls=[ + SimpleNamespace( + id="call-1", + type="function", + function=SimpleNamespace(name="lookup", arguments='{"id":1}'), + ) + ], + ), + finish_reason="tool_calls", + ) + ], usage=SimpleNamespace(prompt_tokens=3, completion_tokens=4, total_tokens=7), ) - provider._get_client = lambda: SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: response))) + provider._get_client = lambda: SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **_: response)) + ) result = provider.complete(CompletionRequest(prompt="hi", stream=False)) assert result.text == "answer" assert result.tool_calls[0]["name"] == "lookup" @@ -1007,9 +1033,7 @@ def test_openai_responses_verbosity_uses_text_config(): provider._protocol = "responses" provider._options = {} - request = provider._build_responses_request( - prompt="hi", stream=False, verbosity="low" - ) + request = provider._build_responses_request(prompt="hi", stream=False, verbosity="low") assert request["text"] == {"verbosity": "low"} assert "verbosity" not in request @@ -1210,9 +1234,9 @@ def test_factory_protocol_status_uses_runtime_verified_options(): ], ) def test_factory_protocol_status_normalizes_protocol_aliases(provider, alias, canonical): - assert ModelProviderFactory.protocol_status(provider, alias) == ModelProviderFactory.protocol_status( - provider, canonical - ) + assert ModelProviderFactory.protocol_status( + provider, alias + ) == ModelProviderFactory.protocol_status(provider, canonical) def test_factory_google_protocol_status_accepts_safe_http_options(): @@ -1348,9 +1372,7 @@ def test_ark_responses_validates_session_mapping_and_conversation_conflicts(): ) request = provider._build_responses_request( - CompletionRequest( - prompt="hi", stream=False, conversation={"id": "conversation-id"} - ) + CompletionRequest(prompt="hi", stream=False, conversation={"id": "conversation-id"}) ) assert request["session"] == {"id": "conversation-id"} @@ -1372,9 +1394,7 @@ def create(self, **kwargs): with pytest.raises(ValueError, match="model"): provider.resources.create_response(input="raw") with pytest.raises(ValueError, match="unknown_option"): - provider.resources.create_response( - input="raw", model="caller-model", unknown_option=True - ) + provider.resources.create_response(input="raw", model="caller-model", unknown_option=True) assert provider.resources.create_response(input="raw", model="caller-model") == "response" assert calls == [{"input": "raw", "model": "caller-model"}] @@ -1436,9 +1456,7 @@ def test_security_allows_non_credential_header_descriptors(): def test_security_scans_string_values_for_embedded_credentials(): - found = find_sensitive_option_paths( - {"X-Trace": "Bearer secret-token", "tenant": "safe"} - ) + found = find_sensitive_option_paths({"X-Trace": "Bearer secret-token", "tenant": "safe"}) assert "X-Trace" in found @@ -1566,10 +1584,12 @@ def test_google_async_invoke_stream_and_non_stream_issue_one_request_each(): class AsyncModels: def generate_content_stream(self, **_kwargs): calls["stream"] += 1 - return iter([ - SimpleNamespace(text="流"), - SimpleNamespace(candidates=[SimpleNamespace(finish_reason="STOP")]), - ]) + return iter( + [ + SimpleNamespace(text="流"), + SimpleNamespace(candidates=[SimpleNamespace(finish_reason="STOP")]), + ] + ) async def generate_content(self, **_kwargs): calls["complete"] += 1 @@ -1578,9 +1598,9 @@ async def generate_content(self, **_kwargs): provider._get_client = lambda: SimpleNamespace(aio=SimpleNamespace(models=AsyncModels())) async def collect(stream): - return [chunk async for chunk in provider.ainvoke( - prompt="hi", stream=stream, temperature=None - )] + return [ + chunk async for chunk in provider.ainvoke(prompt="hi", stream=stream, temperature=None) + ] assert asyncio.run(collect(True)) == ["流"] assert calls == {"stream": 1, "complete": 0} @@ -1604,7 +1624,9 @@ def test_google_http_extensions_map_to_sdk_http_options(): provider._options = {} config = provider._build_generation_config( - "system", None, 0.2, + "system", + None, + 0.2, extra_headers={"X-Trace": "trace-1"}, extra_body={"tenant": "demo"}, timeout=1.25, @@ -1681,8 +1703,10 @@ async def embed_content(self, **kwargs): provider._get_client = lambda: SimpleNamespace(aio=SimpleNamespace(models=AsyncModels())) request = CompletionRequest( - prompt="hi", extra_headers={"X-Trace": "trace-2"}, - extra_body={"tenant": "demo"}, timeout=2, + prompt="hi", + extra_headers={"X-Trace": "trace-2"}, + extra_body={"tenant": "demo"}, + timeout=2, ) assert asyncio.run(provider.async_count_tokens(request)) == 7 @@ -1691,9 +1715,13 @@ async def embed_content(self, **kwargs): assert count_http.extra_body == {"tenant": "demo"} assert count_http.timeout == 2000 - assert asyncio.run(provider.aembed_documents( - ["doc"], extra_headers={"X-Trace": "trace-3"}, timeout=3, - )) == [[0.1, 0.2]] + assert asyncio.run( + provider.aembed_documents( + ["doc"], + extra_headers={"X-Trace": "trace-3"}, + timeout=3, + ) + ) == [[0.1, 0.2]] embed_config = seen["embed"]["config"] assert embed_config.http_options.headers == {"X-Trace": "trace-3"} assert embed_config.http_options.timeout == 3000 @@ -1784,13 +1812,19 @@ def test_google_stream_events_cover_text_thought_tool_usage_and_finish(): finish_reason="STOP", ) ], - usage_metadata=SimpleNamespace(prompt_token_count=2, candidates_token_count=3, total_token_count=5), + usage_metadata=SimpleNamespace( + prompt_token_count=2, candidates_token_count=3, total_token_count=5 + ), ) events = GoogleProvider._stream_events_from_chunk(chunk) assert [event.type for event in events] == [ - "reasoning_delta", "tool_call_delta", "text_delta", "finish", "usage" + "reasoning_delta", + "tool_call_delta", + "text_delta", + "finish", + "usage", ] assert events[1].tool_call["arguments"] == {"id": 1} assert events[-1].usage["total_tokens"] == 5 @@ -1917,7 +1951,11 @@ def test_google_sync_stream_merges_tool_call_when_id_arrives_after_first_chunk() provider._options = {} def chunk(function_call=None, finish_reason=None): - content = SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) if function_call else None + content = ( + SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) + if function_call + else None + ) return SimpleNamespace( response_id="response-1", candidates=[SimpleNamespace(content=content, finish_reason=finish_reason)], @@ -1955,7 +1993,11 @@ def test_google_sync_stream_defers_unidentified_tool_completion_until_finish(): provider._options = {} def chunk(function_call=None, finish_reason=None): - content = SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) if function_call else None + content = ( + SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) + if function_call + else None + ) return SimpleNamespace( response_id="response-1", candidates=[SimpleNamespace(content=content, finish_reason=finish_reason)], @@ -2014,7 +2056,11 @@ def test_google_async_stream_merges_tool_call_when_id_arrives_after_first_chunk( provider._options = {} def chunk(function_call=None, finish_reason=None): - content = SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) if function_call else None + content = ( + SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) + if function_call + else None + ) return SimpleNamespace( response_id="response-1", candidates=[SimpleNamespace(content=content, finish_reason=finish_reason)], @@ -2088,7 +2134,11 @@ def test_google_sync_stream_keeps_parallel_tool_calls_separate_without_index(): provider._options = {} def chunk(function_call=None, finish_reason=None): - content = SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) if function_call else None + content = ( + SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) + if function_call + else None + ) return SimpleNamespace( response_id="response-1", candidates=[SimpleNamespace(content=content, finish_reason=finish_reason)], @@ -2126,7 +2176,11 @@ def test_google_async_stream_keeps_parallel_tool_calls_separate_without_index(): provider._options = {} def chunk(function_call=None, finish_reason=None): - content = SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) if function_call else None + content = ( + SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) + if function_call + else None + ) return SimpleNamespace( response_id="response-1", candidates=[SimpleNamespace(content=content, finish_reason=finish_reason)], @@ -2164,7 +2218,11 @@ def test_google_sync_stream_migrates_parallel_unidentified_calls_by_name(): provider._options = {} def chunk(function_call=None, finish_reason=None): - content = SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) if function_call else None + content = ( + SimpleNamespace(parts=[SimpleNamespace(function_call=function_call)]) + if function_call + else None + ) return SimpleNamespace( response_id="response-1", candidates=[SimpleNamespace(content=content, finish_reason=finish_reason)], @@ -2229,7 +2287,12 @@ def test_google_contents_links_function_call_content_to_followup_tool_result(): def test_google_tool_result_numeric_prefix_that_is_not_json_stays_text(value): contents = GoogleProvider._contents( [ - {"role": "assistant", "content": [{"type": "function_call", "id": "call-1", "name": "lookup", "arguments": {}}]}, + { + "role": "assistant", + "content": [ + {"type": "function_call", "id": "call-1", "name": "lookup", "arguments": {}} + ], + }, {"role": "tool", "tool_call_id": "call-1", "content": value}, ], None, @@ -2243,7 +2306,12 @@ def test_google_tool_result_numeric_prefix_that_is_not_json_stays_text(value): def test_google_tool_result_complete_json_number_is_decoded(value): contents = GoogleProvider._contents( [ - {"role": "assistant", "content": [{"type": "function_call", "id": "call-1", "name": "lookup", "arguments": {}}]}, + { + "role": "assistant", + "content": [ + {"type": "function_call", "id": "call-1", "name": "lookup", "arguments": {}} + ], + }, {"role": "tool", "tool_call_id": "call-1", "content": value}, ], None, @@ -2256,11 +2324,16 @@ def test_google_tool_result_complete_json_number_is_decoded(value): def test_anthropic_stream_events_cover_text_thinking_tool_usage_and_finish(): events = [ AnthropicProvider._stream_event( - SimpleNamespace(type="content_block_delta", delta=SimpleNamespace(type="text_delta", text="答")), + SimpleNamespace( + type="content_block_delta", delta=SimpleNamespace(type="text_delta", text="答") + ), "msg-1", ), AnthropicProvider._stream_event( - SimpleNamespace(type="content_block_delta", delta=SimpleNamespace(type="thinking_delta", thinking="想")), + SimpleNamespace( + type="content_block_delta", + delta=SimpleNamespace(type="thinking_delta", thinking="想"), + ), "msg-1", ), AnthropicProvider._stream_event( @@ -2282,7 +2355,10 @@ def test_anthropic_stream_events_cover_text_thinking_tool_usage_and_finish(): ] assert [event.type for event in events if event] == [ - "text_delta", "reasoning_delta", "tool_call_delta", "finish" + "text_delta", + "reasoning_delta", + "tool_call_delta", + "finish", ] assert events[3].finish_reason == "tool_use" assert events[3].usage["output_tokens"] == 3 @@ -2305,7 +2381,9 @@ def test_ark_stream_events_cover_chat_and_responses_terminal_metadata(): SimpleNamespace( type="response.completed", response=SimpleNamespace( - id="ark-1", status="completed", usage=SimpleNamespace(input_tokens=1, output_tokens=2) + id="ark-1", + status="completed", + usage=SimpleNamespace(input_tokens=1, output_tokens=2), ), ) ) @@ -2329,9 +2407,7 @@ def test_ark_invoke_non_stream_rejects_failed_response_status(): class Responses: def create(self, **_kwargs): - return SimpleNamespace( - status="failed", error=SimpleNamespace(message="ark failed") - ) + return SimpleNamespace(status="failed", error=SimpleNamespace(message="ark failed")) provider._get_client = lambda: SimpleNamespace(responses=Responses()) @@ -2607,7 +2683,9 @@ def test_anthropic_complete_extracts_tool_blocks_and_usage(): usage=SimpleNamespace(input_tokens=4, output_tokens=5), stop_reason="tool_use", ) - provider._get_client = lambda: SimpleNamespace(messages=SimpleNamespace(create=lambda **_: response)) + provider._get_client = lambda: SimpleNamespace( + messages=SimpleNamespace(create=lambda **_: response) + ) result = provider.complete(CompletionRequest(prompt="hi", stream=False)) assert result.text == "answer" assert result.tool_calls[0]["arguments"] == {"id": 1} @@ -2648,9 +2726,10 @@ def compact(self, **kwargs): assert calls["parse"]["model"] == "demo" assert "stream" not in calls["parse"] provider._protocol = "responses" - assert provider.compact_responses( - input="hello", previous_response_id="resp-1", timeout=2 - ) == "compacted" + assert ( + provider.compact_responses(input="hello", previous_response_id="resp-1", timeout=2) + == "compacted" + ) assert calls["compact"] == { "model": "demo", "input": "hello", @@ -2736,7 +2815,8 @@ def test_openai_official_base_url_allows_responses_resource_contract(): ) assert provider.retrieve_response("resp-1", include=["output"]) == ( - "resp-1", {"include": ["output"]} + "resp-1", + {"include": ["output"]}, ) @@ -2753,9 +2833,13 @@ def retrieve(self, output_item_id, **kwargs): provider._get_client = lambda: SimpleNamespace( evals=SimpleNamespace(runs=SimpleNamespace(output_items=OutputItems())) ) - assert provider.retrieve_eval_run_output_item("eval-1", "run-1", "item-1", limit=2) == "retrieved" + assert ( + provider.retrieve_eval_run_output_item("eval-1", "run-1", "item-1", limit=2) == "retrieved" + ) assert calls["sync"] == ("item-1", {"eval_id": "eval-1", "run_id": "run-1", "limit": 2}) - assert provider.resources.retrieve_eval_run_output_item("eval-1", "run-1", "item-1") == "retrieved" + assert ( + provider.resources.retrieve_eval_run_output_item("eval-1", "run-1", "item-1") == "retrieved" + ) class AsyncOutputItems: async def retrieve(self, output_item_id, **kwargs): @@ -2765,13 +2849,19 @@ async def retrieve(self, output_item_id, **kwargs): provider._get_aclient = lambda: SimpleNamespace( evals=SimpleNamespace(runs=SimpleNamespace(output_items=AsyncOutputItems())) ) - assert asyncio.run( - provider.async_retrieve_eval_run_output_item("eval-1", "run-1", "item-1", limit=3) - ) == "async-retrieved" + assert ( + asyncio.run( + provider.async_retrieve_eval_run_output_item("eval-1", "run-1", "item-1", limit=3) + ) + == "async-retrieved" + ) assert calls["async"] == ("item-1", {"eval_id": "eval-1", "run_id": "run-1", "limit": 3}) - assert asyncio.run( - provider.async_resources.retrieve_eval_run_output_item("eval-1", "run-1", "item-1") - ) == "async-retrieved" + assert ( + asyncio.run( + provider.async_resources.retrieve_eval_run_output_item("eval-1", "run-1", "item-1") + ) + == "async-retrieved" + ) def test_resource_facade_exposes_native_sdk_clients(): @@ -2956,9 +3046,10 @@ async def register_files(self, **kwargs): ) assert provider.resources.register_files(auth=auth, uris=["gs://bucket/file"]) == "registered" - assert asyncio.run( - provider.async_resources.register_files(auth=auth, uris=["gs://bucket/file"]) - ) == "async-registered" + assert ( + asyncio.run(provider.async_resources.register_files(auth=auth, uris=["gs://bucket/file"])) + == "async-registered" + ) assert calls == { "sync": {"auth": auth, "uris": ["gs://bucket/file"]}, "async": {"auth": auth, "uris": ["gs://bucket/file"]}, @@ -2993,9 +3084,20 @@ def test_anthropic_beta_resource_facade_forwards_current_sdk_tree(): resources = { name: object() for name in ( - "agents", "deployments", "deployment_runs", "dreams", "environments", - "files", "memory_stores", "models", "sessions", "skills", "tunnels", - "user_profiles", "vaults", "webhooks", + "agents", + "deployments", + "deployment_runs", + "dreams", + "environments", + "files", + "memory_stores", + "models", + "sessions", + "skills", + "tunnels", + "user_profiles", + "vaults", + "webhooks", ) } provider._get_client = lambda: SimpleNamespace(beta=SimpleNamespace(**resources)) @@ -3044,7 +3146,9 @@ def update(self, **kwargs): provider._get_client = lambda: SimpleNamespace(models=Models()) assert provider.resources.delete_model("models/demo") == "deleted" - assert provider.resources.update_model("models/demo", config={"display_name": "demo"}) == "updated" + assert ( + provider.resources.update_model("models/demo", config={"display_name": "demo"}) == "updated" + ) assert calls["delete"] == {"model": "models/demo"} assert calls["update"] == {"model": "models/demo", "config": {"display_name": "demo"}} @@ -3080,9 +3184,7 @@ def get(self, operation, **kwargs): calls["operation"] = (operation, kwargs) return "operation" - provider._get_client = lambda: SimpleNamespace( - aio=SimpleNamespace(operations=Operations()) - ) + provider._get_client = lambda: SimpleNamespace(aio=SimpleNamespace(operations=Operations())) assert asyncio.run(provider.async_get_operation("operations/2", timeout=2)) == "operation" operation, kwargs = calls["operation"] @@ -3103,9 +3205,12 @@ def create(self, **kwargs): provider._get_client = lambda: SimpleNamespace(chats=Chats()) - assert provider.create_chat( - history=[], temperature=0.2, max_output_tokens=64, extra_headers={"X-Trace": "1"} - ) == "chat" + assert ( + provider.create_chat( + history=[], temperature=0.2, max_output_tokens=64, extra_headers={"X-Trace": "1"} + ) + == "chat" + ) assert calls["sync"]["model"] == "gemini-default" assert calls["sync"]["history"] == [] assert calls["sync"]["config"].temperature == 0.2 @@ -3124,9 +3229,7 @@ def create(self, **kwargs): calls["async"] = kwargs return ("chat", kwargs) - provider._get_client = lambda: SimpleNamespace( - aio=SimpleNamespace(chats=Chats()) - ) + provider._get_client = lambda: SimpleNamespace(aio=SimpleNamespace(chats=Chats())) result = asyncio.run( provider.async_create_chat(history=[], top_p=0.8, response_mime_type="application/json") @@ -3245,19 +3348,21 @@ def count(self, **kwargs): ) assert provider.count_input_tokens(request) == 11 - assert calls == [{ - "model": "demo", - "input": "hi", - "instructions": "You are a helpful assistant.", - "parallel_tool_calls": True, - "reasoning": {"effort": "low"}, - "text": {"format": {"type": "json_object"}}, - "truncation": "auto", - "extra_headers": {"X-Trace": "trace-1"}, - "extra_query": {"tenant": "demo"}, - "extra_body": {"vendor_flag": True}, - "timeout": 3, - }] + assert calls == [ + { + "model": "demo", + "input": "hi", + "instructions": "You are a helpful assistant.", + "parallel_tool_calls": True, + "reasoning": {"effort": "low"}, + "text": {"format": {"type": "json_object"}}, + "truncation": "auto", + "extra_headers": {"X-Trace": "trace-1"}, + "extra_query": {"tenant": "demo"}, + "extra_body": {"vendor_flag": True}, + "timeout": 3, + } + ] def test_openai_responses_token_count_converts_chat_tool_choice(): @@ -3318,7 +3423,8 @@ def test_openai_compatible_verified_responses_resource_is_forwarded(): ) assert provider.retrieve_response("resp-1", include=["output"]) == ( - "resp-1", {"include": ["output"]} + "resp-1", + {"include": ["output"]}, ) @@ -3482,9 +3588,7 @@ async def create(self, **kwargs): calls.append(kwargs) return "beta" - provider._get_aclient = lambda: SimpleNamespace( - beta=SimpleNamespace(messages=Messages()) - ) + provider._get_aclient = lambda: SimpleNamespace(beta=SimpleNamespace(messages=Messages())) with pytest.raises(ValueError, match="create 不支持 output_format"): asyncio.run( @@ -3531,11 +3635,15 @@ def test_anthropic_request_keys_match_installed_sdk_create_signatures(): provider._options = {} params = provider._build_message_params(prompt="hi") - create_keys = set(inspect.signature(anthropic.Anthropic(api_key="x").messages.create).parameters) + create_keys = set( + inspect.signature(anthropic.Anthropic(api_key="x").messages.create).parameters + ) assert set(params).issubset(create_keys) beta_params = provider._build_message_params(prompt="hi", beta=True) - beta_keys = set(inspect.signature(anthropic.Anthropic(api_key="x").beta.messages.create).parameters) + beta_keys = set( + inspect.signature(anthropic.Anthropic(api_key="x").beta.messages.create).parameters + ) assert set(beta_params).issubset(beta_keys) @@ -3661,9 +3769,7 @@ def test_google_async_stream_and_chat_contracts_do_not_double_await(): class AsyncModels: async def generate_content_stream(self, **_kwargs): async def chunks(): - yield SimpleNamespace( - candidates=[SimpleNamespace(finish_reason="STOP")] - ) + yield SimpleNamespace(candidates=[SimpleNamespace(finish_reason="STOP")]) return chunks() @@ -3671,14 +3777,13 @@ class AsyncChats: def create(self, **kwargs): return ("chat", kwargs) - client = SimpleNamespace( - aio=SimpleNamespace(models=AsyncModels(), chats=AsyncChats()) - ) + client = SimpleNamespace(aio=SimpleNamespace(models=AsyncModels(), chats=AsyncChats())) provider._get_client = lambda: client events = asyncio.run(_collect_google_async_events(provider)) assert [event.type for event in events] == ["finish"] assert asyncio.run(provider.async_create_chat(history=[])) == ( - "chat", {"model": "gemini", "history": []} + "chat", + {"model": "gemini", "history": []}, ) @@ -3727,9 +3832,14 @@ def import_file(self, **kwargs): calls.append(("import", kwargs)) return "imported" - provider._get_client = lambda: SimpleNamespace(tunings=Tunings(), file_search_stores=FileSearchStores()) + provider._get_client = lambda: SimpleNamespace( + tunings=Tunings(), file_search_stores=FileSearchStores() + ) assert provider.resources.tune("gemini-base", {"examples": []}, config={}) == "tuning" - assert provider.resources.validate_tuning_reward("jobs/1", {"text": "ok"}, {"input": "x"}) == "reward" + assert ( + provider.resources.validate_tuning_reward("jobs/1", {"text": "ok"}, {"input": "x"}) + == "reward" + ) assert provider.resources.import_file_to_file_search_store("stores/1", "files/1") == "imported" assert calls == [ ( @@ -3740,7 +3850,10 @@ def import_file(self, **kwargs): "config": {}, }, ), - ("reward", {"parent": "jobs/1", "sample_response": {"text": "ok"}, "example": {"input": "x"}}), + ( + "reward", + {"parent": "jobs/1", "sample_response": {"text": "ok"}, "example": {"input": "x"}}, + ), ("import", {"file_search_store_name": "stores/1", "file_name": "files/1"}), ] @@ -3796,9 +3909,7 @@ def list(self, **kwargs): assert provider.resources.experimental.files.list(limit=2) == "page" assert calls == [{"limit": 2}] with pytest.raises(ValueError, match="凭证"): - provider.resources.experimental.files.list( - extra_query={"access_token": "secret"} - ) + provider.resources.experimental.files.list(extra_query={"access_token": "secret"}) def test_dynamic_native_resource_proxy_rejects_sensitive_positional_arguments(): @@ -3813,7 +3924,9 @@ def connect(self, *args, **kwargs): provider._provider = "openai" provider._get_client = lambda: SimpleNamespace(responses=Responses()) - assert provider.resources.responses.connect({"tenant": "demo"}, {"X-Trace": "1"}) == "connection" + assert ( + provider.resources.responses.connect({"tenant": "demo"}, {"X-Trace": "1"}) == "connection" + ) assert calls == [ (({"tenant": "demo"}, {"X-Trace": "1"}), {}), ] @@ -3909,9 +4022,7 @@ def search(self, **kwargs): provider._provider = "openai" provider._get_client = lambda: SimpleNamespace(vector_stores=VectorStores()) - assert provider.resources.vector_stores.search( - vector_store_id="vs_1", query="refund" - ) == "page" + assert provider.resources.vector_stores.search(vector_store_id="vs_1", query="refund") == "page" assert calls == [{"vector_store_id": "vs_1", "query": "refund"}] with pytest.raises(ValueError, match="凭证"): @@ -4001,15 +4112,14 @@ def copy(self, **kwargs): configured = provider.async_resources.with_options(timeout=2) copied = configured.copy(max_retries=1) assert asyncio.run(copied.responses.with_raw_response.create(model="demo")) == "raw-response" - assert asyncio.run( - copied.responses.with_streaming_response.create(model="demo") - ) == "streaming-response" + assert ( + asyncio.run(copied.responses.with_streaming_response.create(model="demo")) + == "streaming-response" + ) assert calls[:2] == [("with_options", {"timeout": 2}), ("copy", {"max_retries": 1})] with pytest.raises(ValueError, match="凭证"): - copied.responses.with_raw_response.create( - extra_headers={"Authorization": "Bearer secret"} - ) + copied.responses.with_raw_response.create(extra_headers={"Authorization": "Bearer secret"}) with pytest.raises(ValueError, match="凭证"): copied.responses.with_streaming_response.create( extra_body={"nested": {"access_token": "secret"}} @@ -4033,14 +4143,18 @@ async def async_tune(self, base_model, training_dataset, config=None, **kwargs): return "async-tuning" provider = Provider() - assert GoogleResources(provider).tune( - "gemini-base", {"examples": []}, labels={"suite": "test"} - ) == "tuning" - assert asyncio.run( - AsyncGoogleResources(provider).tune( - "gemini-base", {"examples": []}, labels={"suite": "test"} + assert ( + GoogleResources(provider).tune("gemini-base", {"examples": []}, labels={"suite": "test"}) + == "tuning" + ) + assert ( + asyncio.run( + AsyncGoogleResources(provider).tune( + "gemini-base", {"examples": []}, labels={"suite": "test"} + ) ) - ) == "async-tuning" + == "async-tuning" + ) assert calls == [ ("sync", "gemini-base", {"examples": []}, None, {"labels": {"suite": "test"}}), ("async", "gemini-base", {"examples": []}, None, {"labels": {"suite": "test"}}), @@ -4079,7 +4193,10 @@ def method(*args, **kwargs): assert provider.resources.create_interaction(input="hello") == "create-result" assert provider.resources.get_webhook("wh-1") == "get-result" assert provider.resources.run_trigger("tr-1", payload={"x": 1}) == "run-result" - assert provider.resources.connect_live(config={}) == ("live", {"model": "gemini-default", "config": {}}) + assert provider.resources.connect_live(config={}) == ( + "live", + {"model": "gemini-default", "config": {}}, + ) async def collect(): return ( @@ -4128,11 +4245,16 @@ def stream(self, **kwargs): ark_provider._model_name = "ark-model" ark_provider._get_client = lambda: SimpleNamespace( beta=SimpleNamespace(chat=SimpleNamespace(completions=Completions())), - bot_chat=SimpleNamespace(completions=SimpleNamespace(create=lambda **kwargs: ("bot", kwargs))), + bot_chat=SimpleNamespace( + completions=SimpleNamespace(create=lambda **kwargs: ("bot", kwargs)) + ), ) assert ark_provider.resources.beta_chat_parse(messages=[], response_format={}) == "parsed" assert ark_provider.resources.beta_chat_stream(messages=[]) == "stream" - assert ark_provider.resources.bot_chat(messages=[]) == ("bot", {"messages": [], "model": "ark-model"}) + assert ark_provider.resources.bot_chat(messages=[]) == ( + "bot", + {"messages": [], "model": "ark-model"}, + ) assert ark_calls == [ ("parse", {"messages": [], "response_format": {}, "model": "ark-model"}), ("stream", {"messages": [], "model": "ark-model"}), @@ -4145,7 +4267,11 @@ def stream(self, **kwargs): ("upload_file", (b"data", "user_data"), {"extra_headers": {"X-API-Key": "secret"}}), ("list_files", (), {"extra_query": {"access_token": "secret"}}), ("retrieve_response", ("resp-1",), {"extra_body": {"token": "secret"}}), - ("create_content_generation_task", (), {"content": [], "extra_body": {"password": "secret"}}), + ( + "create_content_generation_task", + (), + {"content": [], "extra_body": {"password": "secret"}}, + ), ("beta_chat_parse", (), {"messages": [], "extra_query": {"api_key": "secret"}}), ("bot_chat", (), {"messages": [], "extra_headers": {"Authorization": "Bearer secret"}}), ("classify", ("query", ["label"]), {"extra_headers": {"X-Api-Key": "secret"}}), @@ -4246,9 +4372,7 @@ def list(self, response_id, **kwargs): ("list_input_items", ("resp-1",), {"bogus": True}), ], ) -def test_ark_response_resources_reject_unknown_kwargs_before_sdk_call( - method_name, args, kwargs -): +def test_ark_response_resources_reject_unknown_kwargs_before_sdk_call(method_name, args, kwargs): provider = object.__new__(VolcengineProvider) provider._protocol = "responses" provider._server_verified_protocols = frozenset({"responses"}) @@ -4334,9 +4458,7 @@ async def __anext__(self): return Stream() - provider._get_aclient = lambda: SimpleNamespace( - chat=SimpleNamespace(completions=Completions()) - ) + provider._get_aclient = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) async def collect(): return [event async for event in provider.astream_events(CompletionRequest(prompt="hi"))] @@ -4358,14 +4480,19 @@ def test_ark_responses_request_and_stream(monkeypatch): protocol="responses", options={"server_verified_protocols": ["responses"]}, ) - assert provider._build_responses_request(CompletionRequest(prompt="hi", stream=True))["input"] == "hi" + assert ( + provider._build_responses_request(CompletionRequest(prompt="hi", stream=True))["input"] + == "hi" + ) class Responses: def create(self, **_kwargs): return iter( [ SimpleNamespace(type="response.output_text.delta", delta="ok"), - SimpleNamespace(type="response.completed", response=SimpleNamespace(status="completed")), + SimpleNamespace( + type="response.completed", response=SimpleNamespace(status="completed") + ), ] ) @@ -4438,14 +4565,10 @@ async def create(self, **kwargs): ] ) - provider._get_aclient = lambda: SimpleNamespace( - chat=SimpleNamespace(completions=Completions()) - ) + provider._get_aclient = lambda: SimpleNamespace(chat=SimpleNamespace(completions=Completions())) async def consume(): - return [item async for item in provider.ainvoke( - "hello", system_prompt=None, stream=False - )] + return [item async for item in provider.ainvoke("hello", system_prompt=None, stream=False)] assert asyncio.run(consume()) == ["ok"] assert calls[0]["messages"] == [{"role": "user", "content": "hello"}] @@ -4460,9 +4583,7 @@ def test_ark_responses_normalizes_model_options_to_native_shape(): "reasoning_effort": "high", } - request = provider._build_responses_request( - CompletionRequest(prompt="hi", stream=False) - ) + request = provider._build_responses_request(CompletionRequest(prompt="hi", stream=False)) assert request["max_output_tokens"] == 1024 assert request["text"] == {"format": {"type": "json_object"}} @@ -4479,9 +4600,7 @@ def test_ark_responses_converts_configured_tool_choice_to_native_shape(): "tool_choice": {"type": "function", "function": {"name": "lookup"}}, } - request = provider._build_responses_request( - CompletionRequest(prompt="hi", stream=False) - ) + request = provider._build_responses_request(CompletionRequest(prompt="hi", stream=False)) assert request["tool_choice"] == {"type": "function", "name": "lookup"} @@ -4523,13 +4642,18 @@ def create(self, **kwargs): return "response" provider._get_client = lambda: SimpleNamespace(responses=Responses()) - assert provider.resources.create_response(CompletionRequest(prompt="hi", stream=False)) == "response" - assert calls == [{ - "model": "ark-model", - "input": "hi", - "stream": False, - "instructions": "You are a helpful assistant.", - }] + assert ( + provider.resources.create_response(CompletionRequest(prompt="hi", stream=False)) + == "response" + ) + assert calls == [ + { + "model": "ark-model", + "input": "hi", + "stream": False, + "instructions": "You are a helpful assistant.", + } + ] with pytest.raises(ValueError, match="model"): provider.resources.create_response(input="raw") assert provider.resources.create_response(input="raw", model="caller-model") == "response" @@ -4617,9 +4741,10 @@ def connect(self, **kwargs): assert provider.resources.delete_environment("env-1") == "deleted" assert provider.resources.get_environment_files("env-1", "src", recursive=True) == "files" assert provider.async_resources.connect_live(config={}) == "live-manager" - assert asyncio.run( - provider.async_resources.get_environment_files("env-1", "src", recursive=True) - ) == "async-files" + assert ( + asyncio.run(provider.async_resources.get_environment_files("env-1", "src", recursive=True)) + == "async-files" + ) assert calls == [ ("create", (), {"network": {}}), ("get", (), {"id": "env-1"}), @@ -4661,7 +4786,9 @@ def test_google_experimental_resources_fail_with_capability_error_when_sdk_missi "async_create_trigger", ], ) -def test_google_async_experimental_resources_fail_with_capability_error_when_sdk_missing(method_name): +def test_google_async_experimental_resources_fail_with_capability_error_when_sdk_missing( + method_name, +): provider = object.__new__(GoogleProvider) provider._get_client = lambda: SimpleNamespace(aio=SimpleNamespace()) @@ -4709,11 +4836,18 @@ async def delete(self, *, task_id, **kwargs): calls.append(("async", task_id, kwargs)) return "async-deleted" - provider._get_client = lambda: SimpleNamespace(content_generation=SimpleNamespace(tasks=Tasks())) - provider._get_aclient = lambda: SimpleNamespace(content_generation=SimpleNamespace(tasks=AsyncTasks())) + provider._get_client = lambda: SimpleNamespace( + content_generation=SimpleNamespace(tasks=Tasks()) + ) + provider._get_aclient = lambda: SimpleNamespace( + content_generation=SimpleNamespace(tasks=AsyncTasks()) + ) assert provider.resources.delete_content_generation_task("task-1", timeout=4) == "deleted" - assert asyncio.run(provider.async_resources.delete_content_generation_task("task-2", timeout=5)) == "async-deleted" + assert ( + asyncio.run(provider.async_resources.delete_content_generation_task("task-2", timeout=5)) + == "async-deleted" + ) assert calls == [ ("sync", "task-1", {"timeout": 4}), ("async", "task-2", {"timeout": 5}), @@ -4752,9 +4886,9 @@ async def create(self, **kwargs): batch=SimpleNamespace(chat=SimpleNamespace(completions=Completions())) ) - assert asyncio.run( - provider.async_create_batch_chat(messages=[], user="user-1") - ) == "async-batch" + assert ( + asyncio.run(provider.async_create_batch_chat(messages=[], user="user-1")) == "async-batch" + ) assert calls == [{"messages": [], "model": "ark-model", "user": "user-1"}] @@ -4781,7 +4915,10 @@ def create(self, **kwargs): assert provider.context_complete(context_id="ctx-1", messages=[], stream=False) == "completion" assert calls == [ ("create", {"messages": [], "mode": "session", "model": "ark-model"}), - ("complete", {"context_id": "ctx-1", "messages": [], "stream": False, "model": "ark-model"}), + ( + "complete", + {"context_id": "ctx-1", "messages": [], "stream": False, "model": "ark-model"}, + ), ] with pytest.raises(ValueError, match="Context.*temperature"): @@ -4825,10 +4962,14 @@ def create(self, **_kwargs): batch=SimpleNamespace(multimodal_embeddings=FailingResource()) ) - with pytest.raises(ValueError, match="Ark Batch Multimodal Embedding 不支持请求参数: sparse_embedding"): + with pytest.raises( + ValueError, match="Ark Batch Multimodal Embedding 不支持请求参数: sparse_embedding" + ): provider.create_batch_multimodal_embedding(input=[], sparse_embedding={"enabled": True}) - with pytest.raises(ValueError, match="Ark Batch Multimodal Embedding 不支持请求参数: sparse_embedding"): + with pytest.raises( + ValueError, match="Ark Batch Multimodal Embedding 不支持请求参数: sparse_embedding" + ): asyncio.run( provider.async_create_batch_multimodal_embedding( input=[], sparse_embedding={"enabled": True} @@ -4890,23 +5031,30 @@ def stream(self, **kwargs): provider._get_aclient = lambda: SimpleNamespace(responses=AsyncResponses()) assert provider.stream_responses(CompletionRequest(prompt="hi", stream=True)) == "sync-stream" - assert provider.async_stream_responses(CompletionRequest(prompt="hi", stream=True)) == "async-stream" + assert ( + provider.async_stream_responses(CompletionRequest(prompt="hi", stream=True)) + == "async-stream" + ) assert calls == [ - ("sync", { - "model": "demo", - "input": "hi", - "instructions": "You are a helpful assistant.", - }), - ("async", { - "model": "demo", - "input": "hi", - "instructions": "You are a helpful assistant.", - }), + ( + "sync", + { + "model": "demo", + "input": "hi", + "instructions": "You are a helpful assistant.", + }, + ), + ( + "async", + { + "model": "demo", + "input": "hi", + "instructions": "You are a helpful assistant.", + }, + ), ] - assert provider.stream_responses( - input="hi", safety_identifier="user-1" - ) == "sync-stream" + assert provider.stream_responses(input="hi", safety_identifier="user-1") == "sync-stream" assert calls[-1] == ( "sync", {"input": "hi", "model": "demo", "safety_identifier": "user-1"}, @@ -4945,26 +5093,19 @@ def stream(self, **kwargs): assert provider.stream_responses(response_id="resp-1") == "sync-stream" assert calls[-1] == ("sync", {"response_id": "resp-1"}) - assert ( - provider.stream_responses(response_id="resp-1", starting_after=2) - == "sync-stream" - ) + assert provider.stream_responses(response_id="resp-1", starting_after=2) == "sync-stream" assert calls[-1] == ( "sync", {"response_id": "resp-1", "starting_after": 2}, ) - assert ( - provider.async_stream_responses(response_id="resp-1", starting_after=2) - == "async-stream" - ) + assert provider.async_stream_responses(response_id="resp-1", starting_after=2) == "async-stream" assert calls[-1] == ( "async", {"response_id": "resp-1", "starting_after": 2}, ) - def test_openai_polling_helpers_match_installed_sdk_parameters_sync(): provider = object.__new__(OpenAICompatibleProvider) provider._model_name = "demo" @@ -5001,23 +5142,41 @@ def poll(self, *args, **kwargs): ) assert provider.wait_for_file("file-1", poll_interval=1.5, max_wait_seconds=20) == "file" - assert provider.poll_vector_store_file("store-1", "file-1", poll_interval_ms=250) == "vector-file" - assert provider.poll_vector_store_file_batch("store-1", "batch-1", poll_interval_ms=300) == "vector-batch" - assert provider.upload_vector_store_file_batch_and_poll( - "store-1", ["a"], max_concurrency=2, file_ids=["f-1"], - poll_interval_ms=400, chunking_strategy={"type": "auto"}, - ) == "uploaded-batch" + assert ( + provider.poll_vector_store_file("store-1", "file-1", poll_interval_ms=250) == "vector-file" + ) + assert ( + provider.poll_vector_store_file_batch("store-1", "batch-1", poll_interval_ms=300) + == "vector-batch" + ) + assert ( + provider.upload_vector_store_file_batch_and_poll( + "store-1", + ["a"], + max_concurrency=2, + file_ids=["f-1"], + poll_interval_ms=400, + chunking_strategy={"type": "auto"}, + ) + == "uploaded-batch" + ) assert provider.poll_video("video-1", poll_interval_ms=500) == "video" assert calls == [ ("wait", {"id": "file-1", "poll_interval": 1.5, "max_wait_seconds": 20}), ("vector_file", ("file-1",), {"vector_store_id": "store-1", "poll_interval_ms": 250}), ("vector_batch", ("batch-1",), {"vector_store_id": "store-1", "poll_interval_ms": 300}), - ("upload_batch", { - "vector_store_id": "store-1", "files": ["a"], "max_concurrency": 2, - "file_ids": ["f-1"], "poll_interval_ms": 400, - "chunking_strategy": {"type": "auto"}, - }), + ( + "upload_batch", + { + "vector_store_id": "store-1", + "files": ["a"], + "max_concurrency": 2, + "file_ids": ["f-1"], + "poll_interval_ms": 400, + "chunking_strategy": {"type": "auto"}, + }, + ), ("video", ("video-1",), {"poll_interval_ms": 500}), ] @@ -5049,13 +5208,20 @@ def create(self, *args, **kwargs): vector_stores=SimpleNamespace(files=VectorFiles()), containers=SimpleNamespace(files=ContainerFiles()), ) - assert provider.upload_vector_store_file("store-1", "file", chunking_strategy={"type": "auto"}) == "uploaded" + assert ( + provider.upload_vector_store_file("store-1", "file", chunking_strategy={"type": "auto"}) + == "uploaded" + ) assert provider.create_container_file("container-1", file_id="file-1") == "container-file" assert calls == [ - ("upload", { - "vector_store_id": "store-1", "file": "file", - "chunking_strategy": {"type": "auto"}, - }), + ( + "upload", + { + "vector_store_id": "store-1", + "file": "file", + "chunking_strategy": {"type": "auto"}, + }, + ), ("container", ("container-1",), {"file_id": "file-1"}), ] with pytest.raises(ValueError, match="向量库文件上传.*timeout"): @@ -5075,13 +5241,16 @@ def upload_and_poll(self, **kwargs): vector_stores=SimpleNamespace(files=VectorFiles()) ) - assert provider.upload_vector_store_file_and_poll( - "store-1", - "file", - attributes={"source": "docs", "rank": 1}, - poll_interval_ms=250, - chunking_strategy={"type": "auto"}, - ) == "uploaded-and-polled" + assert ( + provider.upload_vector_store_file_and_poll( + "store-1", + "file", + attributes={"source": "docs", "rank": 1}, + poll_interval_ms=250, + chunking_strategy={"type": "auto"}, + ) + == "uploaded-and-polled" + ) assert calls == [ { "vector_store_id": "store-1", @@ -5108,9 +5277,12 @@ def update(self, *args, **kwargs): vector_stores=SimpleNamespace(files=VectorFiles()) ) - assert provider.update_vector_store_file( - "store-1", "file-1", attributes={"source": "docs", "rank": 1} - ) == "updated" + assert ( + provider.update_vector_store_file( + "store-1", "file-1", attributes={"source": "docs", "rank": 1} + ) + == "updated" + ) assert calls == [ ( ("file-1",), @@ -5141,15 +5313,18 @@ async def upload_file_chunked(self, **kwargs): provider._get_client = lambda: SimpleNamespace(uploads=Uploads()) provider._get_aclient = lambda: SimpleNamespace(uploads=AsyncUploads()) - assert provider.upload_file_chunked( - file=b"payload", - mime_type="text/plain", - purpose="assistants", - filename="notes.txt", - bytes=7, - part_size=4, - md5="md5-value", - ) == "uploaded" + assert ( + provider.upload_file_chunked( + file=b"payload", + mime_type="text/plain", + purpose="assistants", + filename="notes.txt", + bytes=7, + part_size=4, + md5="md5-value", + ) + == "uploaded" + ) async def run(): return await provider.async_upload_file_chunked( @@ -5234,19 +5409,29 @@ async def create(self, *args, **kwargs): ) async def run(): - assert await provider.async_upload_vector_store_file( - "store-1", "file", chunking_strategy={"type": "auto"} - ) == "uploaded" - assert await provider.async_create_container_file("container-1", file_id="file-1") == "container-file" + assert ( + await provider.async_upload_vector_store_file( + "store-1", "file", chunking_strategy={"type": "auto"} + ) + == "uploaded" + ) + assert ( + await provider.async_create_container_file("container-1", file_id="file-1") + == "container-file" + ) with pytest.raises(ValueError, match="向量库文件上传.*timeout"): await provider.async_upload_vector_store_file("store-1", "file", timeout=1) asyncio.run(run()) assert calls == [ - ("upload", { - "vector_store_id": "store-1", "file": "file", - "chunking_strategy": {"type": "auto"}, - }), + ( + "upload", + { + "vector_store_id": "store-1", + "file": "file", + "chunking_strategy": {"type": "auto"}, + }, + ), ("container", ("container-1",), {"file_id": "file-1"}), ] @@ -5265,17 +5450,18 @@ async def upload_and_poll(self, **kwargs): ) async def run(): - assert await provider.async_upload_vector_store_file_and_poll( - "store-1", - "file", - attributes={"source": "docs", "rank": 1}, - poll_interval_ms=250, - chunking_strategy={"type": "auto"}, - ) == "uploaded-and-polled" - with pytest.raises(ValueError, match="向量库文件上传轮询.*timeout"): + assert ( await provider.async_upload_vector_store_file_and_poll( - "store-1", "file", timeout=1 + "store-1", + "file", + attributes={"source": "docs", "rank": 1}, + poll_interval_ms=250, + chunking_strategy={"type": "auto"}, ) + == "uploaded-and-polled" + ) + with pytest.raises(ValueError, match="向量库文件上传轮询.*timeout"): + await provider.async_upload_vector_store_file_and_poll("store-1", "file", timeout=1) asyncio.run(run()) assert calls == [ @@ -5337,18 +5523,38 @@ async def poll(self, *args, **kwargs): ) async def run(): - assert await provider.async_wait_for_file("file-1", poll_interval=1.5, max_wait_seconds=20) == "file" - assert await provider.async_poll_vector_store_file("store-1", "file-1", poll_interval_ms=250) == "vector-file" - assert await provider.async_poll_vector_store_file_batch("store-1", "batch-1", poll_interval_ms=300) == "vector-batch" - assert await provider.async_upload_vector_store_file_batch_and_poll( - "store-1", ["a"], max_concurrency=2, file_ids=["f-1"], - poll_interval_ms=400, chunking_strategy={"type": "auto"}, - ) == "uploaded-batch" + assert ( + await provider.async_wait_for_file("file-1", poll_interval=1.5, max_wait_seconds=20) + == "file" + ) + assert ( + await provider.async_poll_vector_store_file("store-1", "file-1", poll_interval_ms=250) + == "vector-file" + ) + assert ( + await provider.async_poll_vector_store_file_batch( + "store-1", "batch-1", poll_interval_ms=300 + ) + == "vector-batch" + ) + assert ( + await provider.async_upload_vector_store_file_batch_and_poll( + "store-1", + ["a"], + max_concurrency=2, + file_ids=["f-1"], + poll_interval_ms=400, + chunking_strategy={"type": "auto"}, + ) + == "uploaded-batch" + ) assert await provider.async_poll_video("video-1", poll_interval_ms=500) == "video" with pytest.raises(ValueError, match="文件等待.*timeout"): await provider.async_wait_for_file("file-1", timeout=1) with pytest.raises(ValueError, match="向量库文件轮询.*extra_headers"): - await provider.async_poll_vector_store_file("store-1", "file-1", extra_headers={"X-Test": "1"}) + await provider.async_poll_vector_store_file( + "store-1", "file-1", extra_headers={"X-Test": "1"} + ) with pytest.raises(ValueError, match="向量库文件批次上传轮询.*timeout"): await provider.async_upload_vector_store_file_batch_and_poll("store-1", [], timeout=1) with pytest.raises(ValueError, match="视频轮询.*extra_query"): @@ -5359,11 +5565,17 @@ async def run(): ("wait", {"id": "file-1", "poll_interval": 1.5, "max_wait_seconds": 20}), ("vector_file", ("file-1",), {"vector_store_id": "store-1", "poll_interval_ms": 250}), ("vector_batch", ("batch-1",), {"vector_store_id": "store-1", "poll_interval_ms": 300}), - ("upload_batch", { - "vector_store_id": "store-1", "files": ["a"], "max_concurrency": 2, - "file_ids": ["f-1"], "poll_interval_ms": 400, - "chunking_strategy": {"type": "auto"}, - }), + ( + "upload_batch", + { + "vector_store_id": "store-1", + "files": ["a"], + "max_concurrency": 2, + "file_ids": ["f-1"], + "poll_interval_ms": 400, + "chunking_strategy": {"type": "auto"}, + }, + ), ("video", ("video-1",), {"poll_interval_ms": 500}), ] @@ -5386,9 +5598,10 @@ async def wait_for_processing(self, **kwargs): provider._get_aclient = lambda: SimpleNamespace(files=AsyncFiles()) assert provider.wait_for_file("file-1", poll_interval=2, max_wait_seconds=30) == "file" - assert asyncio.run( - provider.async_wait_for_file("file-2", poll_interval=1, max_wait_seconds=40) - ) == "async-file" + assert ( + asyncio.run(provider.async_wait_for_file("file-2", poll_interval=1, max_wait_seconds=40)) + == "async-file" + ) assert calls == [ ("sync", {"id": "file-1", "poll_interval": 2, "max_wait_seconds": 30}), ("async", {"id": "file-2", "poll_interval": 1, "max_wait_seconds": 40}), @@ -5418,7 +5631,9 @@ def test_ark_file_wait_helpers_validate_identifiers_and_timeouts( provider._get_aclient = lambda: pytest.fail("无效轮询参数不应调用异步 SDK") with pytest.raises(ValueError, match=match): - provider.wait_for_file(file_id, poll_interval=poll_interval, max_wait_seconds=max_wait_seconds) + provider.wait_for_file( + file_id, poll_interval=poll_interval, max_wait_seconds=max_wait_seconds + ) with pytest.raises(ValueError, match=match): asyncio.run( @@ -5459,9 +5674,10 @@ async def create(self, **kwargs): provider._async_classification_resource = AsyncClassification() assert provider.resources.classify("refund", ["billing", "support"]) == "classified" - assert asyncio.run( - provider.async_resources.classify("refund", ["billing", "support"]) - ) == "async-classified" + assert ( + asyncio.run(provider.async_resources.classify("refund", ["billing", "support"])) + == "async-classified" + ) assert calls == [ ("sync", {"query": "refund", "model": "ark-model", "labels": ["billing", "support"]}), ("async", {"query": "refund", "model": "ark-model", "labels": ["billing", "support"]}), @@ -5543,9 +5759,7 @@ def count_tokens(self, **kwargs): async def run() -> None: with pytest.raises(ValueError, match="token count 不支持 output_format"): - await provider.async_count_tokens( - CompletionRequest(prompt="hi", output_format=dict) - ) + await provider.async_count_tokens(CompletionRequest(prompt="hi", output_format=dict)) asyncio.run(run()) @@ -5631,8 +5845,7 @@ def test_chat_completions_normalizes_flat_tool_calls_to_openai_shape(): provider._protocol = "chat_completions" provider._options = {} - flat = {"id": "call-1", "type": "function", "name": "lookup", - "arguments": '{"id":1}'} + flat = {"id": "call-1", "type": "function", "name": "lookup", "arguments": '{"id":1}'} request = provider._build_chat_request( messages=[ {"role": "user", "content": "查询"}, @@ -5643,11 +5856,13 @@ def test_chat_completions_normalizes_flat_tool_calls_to_openai_shape(): ) tool_calls = request["messages"][1]["tool_calls"] - assert tool_calls == [{ - "id": "call-1", - "type": "function", - "function": {"name": "lookup", "arguments": '{"id":1}'}, - }], "扁平 tool_calls 必须转换为 OpenAI 嵌套结构" + assert tool_calls == [ + { + "id": "call-1", + "type": "function", + "function": {"name": "lookup", "arguments": '{"id":1}'}, + } + ], "扁平 tool_calls 必须转换为 OpenAI 嵌套结构" def test_chat_completions_preserves_native_tool_calls(): @@ -5658,8 +5873,11 @@ def test_chat_completions_preserves_native_tool_calls(): provider._protocol = "chat_completions" provider._options = {} - native = {"id": "call-2", "type": "function", - "function": {"name": "lookup", "arguments": '{"id":2}'}} + native = { + "id": "call-2", + "type": "function", + "function": {"name": "lookup", "arguments": '{"id":2}'}, + } request = provider._build_chat_request( messages=[ {"role": "user", "content": "查询"}, @@ -5684,9 +5902,11 @@ def test_chat_completions_rejects_tool_call_without_function_name(): provider._build_chat_request( messages=[ {"role": "user", "content": "查询"}, - {"role": "assistant", "content": "", - "tool_calls": [{"id": "call-3", "type": "function", - "arguments": "{}"}]}, + { + "role": "assistant", + "content": "", + "tool_calls": [{"id": "call-3", "type": "function", "arguments": "{}"}], + }, ], stream=False, ) @@ -5708,12 +5928,20 @@ def test_normalize_messages_accepts_id_less_tool_calls_for_non_chat_protocols(): "role": "assistant", "content": "", "tool_calls": [ - {"id": None, "type": "function", "name": "get_weather", - "arguments": {"city": "BJ"}} + { + "id": None, + "type": "function", + "name": "get_weather", + "arguments": {"city": "BJ"}, + } ], }, - {"role": "tool", "tool_call_id": "get_weather", - "name": "get_weather", "content": '{"t":20}'}, + { + "role": "tool", + "tool_call_id": "get_weather", + "name": "get_weather", + "content": '{"t":20}', + }, ], ) @@ -5738,12 +5966,20 @@ def test_google_multi_turn_replay_accepts_id_less_tool_calls(): "role": "assistant", "content": "", "tool_calls": [ - {"id": None, "type": "function", "name": "get_weather", - "arguments": {"city": "BJ"}} + { + "id": None, + "type": "function", + "name": "get_weather", + "arguments": {"city": "BJ"}, + } ], }, - {"role": "tool", "tool_call_id": "get_weather", - "name": "get_weather", "content": '{"t":20}'}, + { + "role": "tool", + "tool_call_id": "get_weather", + "name": "get_weather", + "content": '{"t":20}', + }, ], ), None, @@ -5751,10 +5987,7 @@ def test_google_multi_turn_replay_accepts_id_less_tool_calls(): ) function_call = next( - part.function_call - for content in contents - for part in content.parts - if part.function_call + part.function_call for content in contents for part in content.parts if part.function_call ) assert function_call.name == "get_weather" @@ -5767,16 +6000,14 @@ def test_chat_tool_call_normalization_reads_input_alias(): "role": "assistant", "content": "", "tool_calls": [ - {"id": "c1", "type": "custom_tool_call", "name": "n", - "input": '{"a":1}'} + {"id": "c1", "type": "custom_tool_call", "name": "n", "input": '{"a":1}'} ], } ] ) assert normalized[0]["tool_calls"] == [ - {"id": "c1", "type": "function", - "function": {"name": "n", "arguments": '{"a":1}'}} + {"id": "c1", "type": "function", "function": {"name": "n", "arguments": '{"a":1}'}} ] @@ -5791,9 +6022,7 @@ def test_chat_tool_call_normalization_maps_type_to_function(tool_type): { "role": "assistant", "content": "", - "tool_calls": [ - {"id": "x", "type": tool_type, "name": "n", "arguments": "{}"} - ], + "tool_calls": [{"id": "x", "type": tool_type, "name": "n", "arguments": "{}"}], } ] ) @@ -5812,8 +6041,7 @@ def test_chat_completions_still_requires_tool_call_id(): "role": "assistant", "content": "", "tool_calls": [ - {"id": None, "type": "function", "name": "n", - "arguments": "{}"} + {"id": None, "type": "function", "name": "n", "arguments": "{}"} ], } ], @@ -5832,15 +6060,13 @@ def test_responses_input_still_requires_tool_call_id(): "role": "assistant", "content": "", "tool_calls": [ - {"id": None, "type": "function", "name": "n", - "arguments": "{}"} + {"id": None, "type": "function", "name": "n", "arguments": "{}"} ], } ], ) - @pytest.mark.parametrize( "model_name", [ @@ -5866,8 +6092,13 @@ def test_anthropic_sampling_deprecation_covers_new_family_names(model_name): @pytest.mark.parametrize( "model_name", - ["claude-3-5-sonnet-20240620", "claude-3-haiku-20240307", "claude-2.1", - "claude-sonnet-4", "claude-opus-4"], + [ + "claude-3-5-sonnet-20240620", + "claude-3-haiku-20240307", + "claude-2.1", + "claude-sonnet-4", + "claude-opus-4", + ], ) def test_anthropic_sampling_deprecation_keeps_legacy_models_permissive(model_name): """4.5 之前的模型仍接受采样控制字段,不能被新家族规则误伤。""" @@ -5930,9 +6161,7 @@ def test_ark_responses_non_stream_extracts_text_from_output_content(): "id": "msg_1", "role": "assistant", "status": "completed", - "content": [ - {"type": "output_text", "text": "这是真实回答。", "annotations": []} - ], + "content": [{"type": "output_text", "text": "这是真实回答。", "annotations": []}], } ] ) @@ -6082,9 +6311,7 @@ def test_ark_responses_rejects_instructions_with_enabled_caching_on_every_path(r def test_ark_responses_rejects_caching_enabled_via_model_options_extra_body(): """模型级 options.extra_body 同样会被并入请求体。""" - provider = _ark_provider_for_request_build( - {"extra_body": {"caching": {"type": "enabled"}}} - ) + provider = _ark_provider_for_request_build({"extra_body": {"caching": {"type": "enabled"}}}) with pytest.raises(ValueError, match="互斥"): provider._build_responses_request(CompletionRequest(prompt="hi")) diff --git a/tests/retrieval/test_parent_child_retrieval.py b/tests/retrieval/test_parent_child_retrieval.py index 7652d89..979ac37 100644 --- a/tests/retrieval/test_parent_child_retrieval.py +++ b/tests/retrieval/test_parent_child_retrieval.py @@ -51,6 +51,7 @@ async def fake_keyword(_query, _top_k): async def run(): task = asyncio.create_task(service._gather_hybrid_results("query", 3)) + async def wait_for_both_starts(): while len(started) < 2: await asyncio.sleep(0) @@ -72,12 +73,16 @@ async def fake_arerank(_query, _documents, top_n): provider.arerank.side_effect = fake_arerank factory = MagicMock(return_value=provider) - monkeypatch.setattr("src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", factory) + monkeypatch.setattr( + "src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", factory + ) session_config = SimpleNamespace( rerank_enabled=True, active_rerank_configuration="siliconflow", top_k=1, - rerank_configurations={"siliconflow": SimpleNamespace(provider="siliconflow", model_name="rerank")}, + rerank_configurations={ + "siliconflow": SimpleNamespace(provider="siliconflow", model_name="rerank") + }, ) documents = [{"page_content": "doc", "score": 0.5, "metadata": {}}] @@ -99,7 +104,9 @@ def test_rerank_provider_cache_refreshes_when_options_change(monkeypatch): first_provider = MagicMock() second_provider = MagicMock() factory = MagicMock(side_effect=[first_provider, second_provider]) - monkeypatch.setattr("src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", factory) + monkeypatch.setattr( + "src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", factory + ) first_config = SimpleNamespace( provider="siliconflow", model_name="rerank", @@ -111,8 +118,13 @@ def test_rerank_provider_cache_refreshes_when_options_change(monkeypatch): options={"timeout": 20}, ) - assert service._get_rerank_provider("siliconflow", {"siliconflow": first_config}) is first_provider - assert service._get_rerank_provider("siliconflow", {"siliconflow": second_config}) is second_provider + assert ( + service._get_rerank_provider("siliconflow", {"siliconflow": first_config}) is first_provider + ) + assert ( + service._get_rerank_provider("siliconflow", {"siliconflow": second_config}) + is second_provider + ) assert factory.call_count == 2 @@ -179,7 +191,9 @@ async def fake_arerank(_query, _documents, top_n): def test_semantic_retrieve_uses_legacy_search_when_method_is_missing(): class LegacyStore: def search(self, query, top_k, search_type="semantic"): - return [{"page_content": query, "score": top_k, "metadata": {"search_type": search_type}}] + return [ + {"page_content": query, "score": top_k, "metadata": {"search_type": search_type}} + ] class UnusedEmbedding: async def embed_query(self, _query): @@ -190,7 +204,9 @@ async def embed_query(self, _query): result = asyncio.run(service._semantic_retrieve("query", 3)) - assert result == [{"page_content": "query", "score": 3, "metadata": {"search_type": "semantic"}}] + assert result == [ + {"page_content": "query", "score": 3, "metadata": {"search_type": "semantic"}} + ] def test_semantic_retrieve_does_not_hide_embedding_attribute_errors(): @@ -317,7 +333,9 @@ def test_retrieve_documents_uses_parent_sidecar_and_overfetches_candidates(monke assert len(results) == 2 assert {doc["metadata"]["parent_id"] for doc in results} == {"parent-1", "parent-2"} assert all(doc["page_content"].startswith("parent content") for doc in results) - assert all(doc["metadata"]["matched_chunk_content"].startswith("child content") for doc in results) + assert all( + doc["metadata"]["matched_chunk_content"].startswith("child content") for doc in results + ) def test_retrieve_documents_collapses_multiple_children_from_same_parent(monkeypatch): diff --git a/tests/retrieval/vdb/test_factory.py b/tests/retrieval/vdb/test_factory.py index f74c339..fe9881d 100644 --- a/tests/retrieval/vdb/test_factory.py +++ b/tests/retrieval/vdb/test_factory.py @@ -19,14 +19,22 @@ def __init__(self, file_path: str | None): def add_documents(self, documents: list[dict[str, Any]]): self.documents.extend(documents) - def search(self, query: str, top_k: int = 5, search_type: str = "semantic") -> list[dict[str, Any]]: - return [{"page_content": f"mock_doc_{i}", "metadata": {"source": "mock"}} for i in range(top_k)] + def search( + self, query: str, top_k: int = 5, search_type: str = "semantic" + ) -> list[dict[str, Any]]: + return [ + {"page_content": f"mock_doc_{i}", "metadata": {"source": "mock"}} for i in range(top_k) + ] async def aadd_documents(self, documents: list[dict[str, Any]]): self.documents.extend(documents) - async def asearch(self, query: str, top_k: int = 5, search_type: str = "semantic") -> list[dict[str, Any]]: - return [{"page_content": f"mock_doc_{i}", "metadata": {"source": "mock"}} for i in range(top_k)] + async def asearch( + self, query: str, top_k: int = 5, search_type: str = "semantic" + ) -> list[dict[str, Any]]: + return [ + {"page_content": f"mock_doc_{i}", "metadata": {"source": "mock"}} for i in range(top_k) + ] def save(self, path: str): """模拟保存操作""" @@ -40,7 +48,8 @@ def load_snapshot(self, snapshot_dir: str): def get_embedding_model(self) -> Any: """模拟获取嵌入模型""" - return MagicMock() # 返回一个模拟的嵌入模型 + return MagicMock() # 返回一个模拟的嵌入模型 + @pytest.fixture(scope="function", autouse=True) def patch_settings(monkeypatch, tmp_path): @@ -67,26 +76,26 @@ def patch_settings(monkeypatch, tmp_path): mock_settings_instance.kb_child_chunk_size = 300 mock_settings_instance.kb_child_chunk_overlap = 30 - with patch('src.utils.config.get_settings', return_value=mock_settings_instance): + with patch("src.utils.config.get_settings", return_value=mock_settings_instance): # 清除 VectorStoreFactory 及其依赖模块的缓存 modules_to_clear = [ - 'src.retrieval.vdb.factory', - 'src.retrieval.vdb.faiss_store', + "src.retrieval.vdb.factory", + "src.retrieval.vdb.faiss_store", ] for module_name in modules_to_clear: if module_name in sys.modules: del sys.modules[module_name] - + # 模拟 FaissStore 类 monkeypatch.setattr("src.retrieval.vdb.faiss_store.FaissStore", MockFaissStore) # 重新导入 VectorStoreFactory,确保它加载的是最新的版本 - if 'src.retrieval.vdb.factory' in sys.modules: - del sys.modules['src.retrieval.vdb.factory'] + if "src.retrieval.vdb.factory" in sys.modules: + del sys.modules["src.retrieval.vdb.factory"] from src.retrieval.vdb.factory import ( VectorStoreFactory as ReloadedVectorStoreFactory, ) - + # 直接模拟 VectorStoreFactory.get_vector_store 方法 def mock_get_vector_store(store_type: str, file_path: str | None = None) -> VectorStoreBase: if store_type.lower() == "faiss": @@ -100,6 +109,7 @@ def mock_get_vector_store(store_type: str, file_path: str | None = None) -> Vect VectorStoreFactory = ReloadedVectorStoreFactory yield + # 测试用例 def test_get_vector_store_faiss_success(): """测试成功获取 FaissStore 实例""" @@ -107,11 +117,13 @@ def test_get_vector_store_faiss_success(): assert isinstance(store, MockFaissStore) assert store.file_path == "/tmp/test_faiss.pkl" + def test_get_vector_store_unsupported_type(): """测试获取不支持的向量存储类型时抛出 ValueError""" with pytest.raises(ValueError, match="不支持的向量存储类型: unsupported"): VectorStoreFactory.get_vector_store("unsupported", "/tmp/test.pkl") + def test_get_default_vector_store_success(): """测试成功获取默认向量存储实例""" store = VectorStoreFactory.get_default_vector_store() @@ -125,6 +137,7 @@ def test_get_default_vector_store_without_loading_existing(): assert isinstance(store, MockFaissStore) assert store.file_path is None + def _write_snapshot(root, snapshot_id, provider, model): """按真实快照目录结构写入一个活动快照,供兼容性检测使用。""" from pathlib import Path diff --git a/tests/retrieval_test/test_retrieval_cli.py b/tests/retrieval_test/test_retrieval_cli.py index 2c3d683..bfafe91 100644 --- a/tests/retrieval_test/test_retrieval_cli.py +++ b/tests/retrieval_test/test_retrieval_cli.py @@ -41,9 +41,16 @@ async def retrieve(self, *_args, **_kwargs): ) monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) monkeypatch.setattr("src.retrieval_test.core.RetrievalService", FailingRetrievalService) - monkeypatch.setattr("src.retrieval_test.core.EmbeddingService", lambda *_args, **_kwargs: object()) - monkeypatch.setattr("src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", lambda: object()) - monkeypatch.setattr("src.retrieval_test.core.console.print", lambda message, *args, **kwargs: printed_messages.append(str(message))) + monkeypatch.setattr( + "src.retrieval_test.core.EmbeddingService", lambda *_args, **_kwargs: object() + ) + monkeypatch.setattr( + "src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", lambda: object() + ) + monkeypatch.setattr( + "src.retrieval_test.core.console.print", + lambda message, *args, **kwargs: printed_messages.append(str(message)), + ) asyncio.run(run_retrieval_test_async()) diff --git a/tests/services/test_embedding_service.py b/tests/services/test_embedding_service.py index 4ecaf5b..ad222c8 100644 --- a/tests/services/test_embedding_service.py +++ b/tests/services/test_embedding_service.py @@ -94,12 +94,15 @@ def test_embedding_service_does_not_replay_model_options(monkeypatch): @pytest.mark.parametrize("method", ["embed_texts", "embed_query", "embed_in_batches"]) -@pytest.mark.parametrize("options", [ - {"dimensions": 2}, - {"encoding_format": "float"}, - {"user": "embedding-test"}, - {"extra_body": {"vendor_flag": True}}, -]) +@pytest.mark.parametrize( + "options", + [ + {"dimensions": 2}, + {"encoding_format": "float"}, + {"user": "embedding-test"}, + {"extra_body": {"vendor_flag": True}}, + ], +) def test_embedding_service_model_options_reach_sdk_once(monkeypatch, method, options): requests = [] @@ -120,12 +123,19 @@ async def create(self, **request): "src.services.embedding_service.ModelProviderFactory.get_embedding_provider", lambda *_args: provider, ) - service = EmbeddingService(SimpleNamespace( - default_embedding_provider="custom", kb_embedding_batch_size=1, - embedding_configurations={"custom": ModelDetail( - provider="openai", model_name="text-embedding-3-small", options=options, - )}, - )) + service = EmbeddingService( + SimpleNamespace( + default_embedding_provider="custom", + kb_embedding_batch_size=1, + embedding_configurations={ + "custom": ModelDetail( + provider="openai", + model_name="text-embedding-3-small", + options=options, + ) + }, + ) + ) value = "one" if method == "embed_query" else ["one", "two"] result = asyncio.run(getattr(service, method)(value)) assert result.dtype == np.float32 diff --git a/tests/test_config.py b/tests/test_config.py index 9635d4d..0098297 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -167,7 +167,10 @@ def test_settings_from_toml(tmp_path): def test_model_protocol_aliases_and_invalid_values(): - assert ModelDetail(provider="openai", model_name="gpt-test", protocol="response").protocol == ModelProtocol.RESPONSES + assert ( + ModelDetail(provider="openai", model_name="gpt-test", protocol="response").protocol + == ModelProtocol.RESPONSES + ) with pytest.raises(ValidationError, match="不支持的模型协议"): ModelDetail(provider="openai", model_name="gpt-test", protocol="unknown") @@ -189,9 +192,17 @@ def test_settings_rejects_protocol_on_non_llm_configuration(field_name): ) -@pytest.mark.parametrize("sensitive_key", [ - "X-API-Key", "X-Auth-Token", "Authorization", "access_token", "api_key", "Bearer", -]) +@pytest.mark.parametrize( + "sensitive_key", + [ + "X-API-Key", + "X-Auth-Token", + "Authorization", + "access_token", + "api_key", + "Bearer", + ], +) def test_model_options_reject_credentials_nested_in_headers_or_query(sensitive_key): with pytest.raises(ValueError, match="凭证"): ModelDetail( @@ -208,11 +219,24 @@ def test_model_options_reject_credentials_nested_in_headers_or_query(sensitive_k ) -@pytest.mark.parametrize("container", [ - "headers", "default_headers", "extra_headers", - "query", "default_query", "extra_query", "http_options", "header", "params", - "http_client", "httpx_client", "httpx_async_client", "aiohttp_client", -]) +@pytest.mark.parametrize( + "container", + [ + "headers", + "default_headers", + "extra_headers", + "query", + "default_query", + "extra_query", + "http_options", + "header", + "params", + "http_client", + "httpx_client", + "httpx_async_client", + "aiohttp_client", + ], +) def test_model_options_reject_request_header_and_query_containers(container): with pytest.raises(ValueError, match="凭证或连接字段"): ModelDetail( @@ -236,7 +260,7 @@ def test_settings_from_dotenv(tmp_path): dotenv_path = tmp_path / ".env" dotenv_path.write_text( 'OPENAI_API_KEY="dotenv_key"\nCHAT_TOP_K=15\n' - 'GOOGLE_GENAI_USE_VERTEXAI=true\n' + "GOOGLE_GENAI_USE_VERTEXAI=true\n" 'GOOGLE_CLOUD_PROJECT="dotenv-project"\n' 'GOOGLE_CLOUD_LOCATION="asia-east1"\n', encoding="utf-8", @@ -320,7 +344,9 @@ def test_settings_path_resolution(monkeypatch): def test_resolve_app_root_source_mode(monkeypatch, tmp_path): - monkeypatch.setattr("src.utils.config.sys", type("FakeSys", (), {"frozen": False, "executable": ""})()) + monkeypatch.setattr( + "src.utils.config.sys", type("FakeSys", (), {"frozen": False, "executable": ""})() + ) monkeypatch.setattr("src.utils.config.__file__", str(tmp_path / "src" / "utils" / "config.py")) root = resolve_app_root() diff --git a/tests/test_release_scripts.py b/tests/test_release_scripts.py index d23d96d..980ae1c 100644 --- a/tests/test_release_scripts.py +++ b/tests/test_release_scripts.py @@ -39,7 +39,9 @@ def test_validate_bundle_uses_smoke_test(monkeypatch, tmp_path): def fake_run(command, **kwargs): recorded["command"] = command recorded["kwargs"] = kwargs - return subprocess.CompletedProcess(command, 0, stdout="PyRAG-Kit 1.4.0 smoke test ok\n", stderr="") + return subprocess.CompletedProcess( + command, 0, stdout="PyRAG-Kit 1.4.0 smoke test ok\n", stderr="" + ) monkeypatch.setattr("scripts.build_binary_release.subprocess.run", fake_run) From 534f334e6d351acaaf0bfdf4a0749a484be03070 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 08:57:40 +0800 Subject: [PATCH 03/31] ci: run quality checks on pull requests and main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本分支引入了格式化配置与 880 项测试,但仓库此前没有任何 CI 执行它们—— `.github/workflows/release.yml` 只在 `v*` 标签触发构建,质量工具实际处于 「加了但无人运行」的状态。 新增 quality.yml,在 PR 与 main 推送时执行:锁文件校验、格式化检查、lint、 类型检查、安全扫描、字节码编译与完整测试。步骤顺序与 AGENTS.md 列出的 本地检查一致;格式化检查放在最前,因为它的失败最容易被后续输出淹没。 已逐条在本地模拟全部 8 步,均通过。 `AGENTS.md` 补充 CI 说明,`CHANGELOG.md` 记录新增工作流与全仓库格式化。 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/quality.yml | 55 +++++++++++++++++++++++++++++++++++ AGENTS.md | 5 +++- CHANGELOG.md | 3 +- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/quality.yml diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..d75d3e5 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,55 @@ +name: quality + +on: + push: + branches: ["main"] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Setup Python + uses: actions/setup-python@v6.2.0 + with: + python-version: "3.11" + + - name: Setup uv + uses: astral-sh/setup-uv@v7.6.0 + + - name: Sync dependencies + run: uv sync --group dev + + - name: Check lockfile is up to date + run: uv lock --check + + # 格式化检查放在最前:它的失败最容易被后续步骤的输出淹没, + # 而且未格式化的代码会让 lint 的 diff 变得难以阅读。 + - name: Check formatting + run: uv run ruff format --check main.py src scripts tests + + - name: Lint + run: uv run ruff check main.py src scripts tests + + - name: Type check + run: uv run mypy --cache-dir /tmp/pyrag-kit-mypy main.py src + + - name: Security scan + run: uv run bandit -r main.py src scripts -ll + + - name: Byte-compile + run: uv run python -m compileall -q main.py src scripts tests + + - name: Test + run: uv run pytest -q + + - name: Check for whitespace errors + run: git diff --check diff --git a/AGENTS.md b/AGENTS.md index 5db3a3c..a903d15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ uv run python scripts/build_binary_release.py --target macos-arm64 --validate 开发依赖包含 Ruff、Bandit 和 MyPy。提交前可运行: ```bash +uv run ruff format --check main.py src scripts tests uv run ruff check main.py src scripts tests uv run bandit -r main.py src scripts -ll uv run mypy --cache-dir /tmp/pyrag-kit-mypy main.py src @@ -40,9 +41,11 @@ git diff --check MyPy 使用任务专用缓存目录,避免多个进程共享 `.mypy_cache` 造成锁等待;优先修复本次改动引入的问题,并记录未覆盖的历史基线告警。 +以上检查由 `.github/workflows/quality.yml` 在每次 PR 与 `main` 推送时执行;本地通过后再提交,避免 CI 往返。 + ## Coding Style & Naming Conventions -使用 4 个空格缩进、明确的公开函数类型标注和简短客观的 docstring。函数、变量和模块使用 `snake_case`,类使用 `PascalCase`,常量使用 `UPPER_CASE`。项目使用 Ruff 做 lint 检查,未配置独立 formatter;修改时遵循相邻代码风格并运行 `git diff --check`。保持服务、provider、检索和快照边界,不用静默回退或占位结果掩盖失败。 +使用 4 个空格缩进、明确的公开函数类型标注和简短客观的 docstring。函数、变量和模块使用 `snake_case`,类使用 `PascalCase`,常量使用 `UPPER_CASE`。项目使用 Ruff 同时做 lint 与格式化,配置见 `pyproject.toml` 的 `[tool.ruff]`(行宽 100、双引号、4 空格);提交前运行 `uv run ruff format main.py src scripts tests`,不要手工对齐或调整引号。保持服务、provider、检索和快照边界,不用静默回退或占位结果掩盖失败。 ## Testing Guidelines diff --git a/CHANGELOG.md b/CHANGELOG.md index a880a1a..f6c825a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,8 @@ - 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 823。 - 为活动快照的 embedding 兼容性检测补充回归测试:快照记录的 embedding provider 或模型名与当前运行配置不一致时必须显式失败,避免用错向量空间后静默产出错误检索结果。 -- 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置。 +- 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置;新增 `.github/workflows/quality.yml`,在 PR 与 `main` 推送时执行格式化检查、lint、类型检查、安全扫描与完整测试,此前这些工具只在本地手动运行。 +- 全仓库应用 `ruff format`(行宽 100、双引号、4 空格),此前未配置 formatter,`main.py`、`src/`、`scripts/`、`tests/` 中存在混用单引号、行尾空白和手工对齐等不一致;格式化只改表示不改语义,已用 AST 比对确认语法树等价,`AGENTS.md` 的质量检查与风格段落同步更新。 - 更新 `README.md`、`AGENTS.md` 与 `docs/`,同步协议选择、`options` 边界、原生资源入口和 Vertex ADC 配置口径。 - 核对各渠道官方现状并刷新配置示例:Google Embedding 改用 `gemini-embedding-2`(`text-embedding-004` 已于 2026-01-14 退役),OpenAI 改用 `gpt-5.6-*`(`gpt-3.5-turbo` 于 2026-10-23 退役),Anthropic 改用 `claude-sonnet-4-6`(`claude-3-5-sonnet-20240620` 已于 2025-10-28 退役),DeepSeek 改用 `deepseek-v4-pro`/`deepseek-v4-flash`(`deepseek-chat` 已于 2026-07-24 退役),火山改用 `doubao-seed-2-0-lite-260428` 与 `doubao-embedding-text-240715`(`doubao-pro-32k`、`bge-large-zh` 已不在方舟模型列表),Qwen 改用 `qwen3.8-max`/`qwen3.7-plus`,SiliconFlow 改用 `Qwen/Qwen3.5-27B`/`deepseek-ai/DeepSeek-V3.2`(复核发现 `Qwen/Qwen3-8B` 已不在该平台在售列表,其 Qwen 对话模型现从 Qwen3.5-27B 起步),Grok 改用 `grok-4.6`(`llama3-70b-8192` 实为 Groq 的 ID,不属于 xAI),Ollama 改用 `llama3.1`/`gemma3`。示例配置的键名同步改为与模型一致,模型内置默认值一并更新;内置 embedding 与 rerank 兜底值此前含无效 ID(Google 的裸名 `embedding-001`、SiliconFlow 不存在的 `alibaba/` 命名空间与已下线的 `bge-reranker-large`、Ollama 的 `llama3`),现分别改为 `gemini-embedding-2`、`BAAI/bge-large-zh-v1.5`、`BAAI/bge-reranker-v2-m3` 与 `llama3.1`。 - 需要重建知识库快照:`gemini-embedding-2` 与 `text-embedding-004` 的向量空间不兼容,旧 FAISS 索引不能复用。火山的 `doubao-embedding-text-240715` 已于 2025-12-26 停止新购(EOM),且已不在方舟「向量化能力」模型列表中;官方下线公告给出的迁移目标是 `doubao-embedding-vision-251215`,该模型同时接受纯文本输入,但它按 `/api/v3/embeddings/multimodal` 提供,与本项目使用的文本 `embeddings.create` 路径不同,迁移前需实测。embedding 模型只有 EOM 阶段、不涉及 EOS,存量接入点不受影响,因此示例配置暂未改动该值。 From ed02a28b21f16ef49dc6ce69a3d3ad49558f437a Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 10:00:36 +0800 Subject: [PATCH 04/31] =?UTF-8?q?fix:=20=E4=BF=AE=E6=AD=A3=E8=84=B1?= =?UTF-8?q?=E6=95=8F=E6=AD=A3=E5=88=99=E7=9A=84=E9=A9=BC=E5=B3=B0=E9=94=AE?= =?UTF-8?q?=E3=80=81=E8=AF=AF=E5=88=A4=E4=B8=8E=E6=8E=A9=E7=A0=81=E5=90=9E?= =?UTF-8?q?=E9=94=AE=E5=9B=9E=E5=BD=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮扩大脱敏词表时引入四处回归,均在提交 7a60a0a 的版本上复现: - 键名前加了「非字母数字」断言,使 dbPassword/myApiKey/userToken 这类 驼峰键在文本里明文输出,而 is_sensitive_option_key 判其为敏感——即 「配置边界拦住、文本边界放过」的不一致。改为与切词口径一致。 - 裸关键词(auth/cookie/secret/token/bearer)后接普通词时无值形态约束, 把「Set auth: none」「cookie: enabled」判成凭证。因 find_sensitive_option_paths 用「值是否被改写」判定,合法 options 与 资源参数会在边界被误拒。补上值的形态约束。 - 掩码尾部字符类含字母数字,会吞掉紧邻的键名,使 sk-abc***token= 里的值从脱敏变明文。改为在键名前停下。 - urlsplit 的 ValueError 回退分支只做值脱敏,畸形 URL 下 ?myApiKey= 这类空值敏感键漏检,而同样内容在可解析 URL 下会被拦下。 回归测试 13 项已按红绿验证:回退 security.py 后全部失败。 Co-Authored-By: Claude Opus 5 (1M context) --- src/utils/security.py | 44 ++++++-- tests/providers/test_security_boundaries.py | 105 ++++++++++++++++++++ 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/utils/security.py b/src/utils/security.py index ce6a0a8..03ba6b5 100644 --- a/src/utils/security.py +++ b/src/utils/security.py @@ -145,11 +145,27 @@ ) _TEXT_CREDENTIAL_KEY_PATTERN = "|".join(_TEXT_CREDENTIAL_KEYS) +# 值的形态约束:凭证值要么被引号包裹,要么含数字或符号,要么足够长。 +# 缺了它,``auth: none``、``cookie: enabled``、``secret: false`` 这类普通文本 +# 会被判为凭证——而 find_sensitive_option_paths 用 +# ``redact_sensitive_text(value) != value`` 判断值里有没有凭证,于是合法配置 +# (例如 vector_stores.search(query="...") 的检索词、extra_body 里的提示词或 +# JSON schema)会在边界被误拒。 +_CREDENTIAL_VALUE_SHAPE = ( + r"""(?:"[^"]*"|'[^']*'""" + r"""|(?=[A-Za-z0-9._~+/=-]*[0-9._~+/=-])[A-Za-z0-9._~+/=-]{2,}""" + r"""|[A-Za-z0-9._~+/=-]{12,})""" +) + _KEY_VALUE_TEXT_RE = re.compile( - r"(?i)(?`` + # 里的 ``token`` 若被吞进掩码匹配,后面的 ``token=`` 就失去锚点, + # 值会从脱敏变成明文(净漏检)。断言在键名之前停下,让键值规则处理它。 + r"(?i)\b(?:sk|rk|sess)-[A-Za-z0-9_.-]{2,}[*\u2026.]{2,}" + r"(?:[A-Za-z0-9_.*-](?![A-Za-z0-9_-]*\s*[:=]))*" ) _GOOGLE_API_KEY_RE = re.compile(r"\bAIza[0-9A-Za-z_-]{20,}\b") @@ -324,8 +345,17 @@ def _url_credential_paths(value: Any, path: str) -> list[str]: # urlsplit 对畸形 URL(例如 ``https://[::1``)抛 ValueError。这不是 # 「值不是 URL」而是「无法解析」,不能就此放弃检查:该分支是叶子值的 # 唯一入口,直接 return 会让任意凭证随一个畸形前缀整体绕过边界校验。 - # 退回文本脱敏判定,保持与正常 URL 路径一致的严格度。 - return [path or "value"] if redact_sensitive_text(value) != value else [] + # 退回手工解析,保持与正常 URL 路径一致的严格度:既要看值里有没有 + # 凭证,也要看 query 的键名(``?myApiKey=`` 这种空值键在正常路径下会被 + # ``is_sensitive_option_key`` 拦下,只做值脱敏会漏掉它)。 + fallback = [path or "value"] if redact_sensitive_text(value) != value else [] + marker = value.find("?") + if marker != -1: + for pair in value[marker + 1 :].split("&"): + key = pair.partition("=")[0] + if key and is_sensitive_option_key(key): + fallback.append(f"{path}?{key}") + return fallback found: list[str] = [] if redact_sensitive_text(value) != value: found.append(path or "value") diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index 72124df..d48660b 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -187,3 +187,108 @@ def test_user_facing_error_paths_do_not_print_raw_exceptions(): assert "safe_exception_text" in source, relative assert 'console.print(f"[red]召回测试出错: {exc}' not in source, relative assert '{e}")' not in source, relative + + +# ── 回归:文本键名口径必须与 is_sensitive_option_key 一致 ── + + +# 键名识别按 camelCase 边界切词,因此这些驼峰键在配置边界判为敏感。 +# 文本正则若要求键名前是非字母数字字符,它们会在日志里明文输出——即 +# 「配置边界拦住、文本边界放过」的不一致,正是本文件要消灭的形态。 +_CAMEL_CASE_REDACTION_KEYS = [ + "dbPassword", + "myApiKey", + "userToken", + "myAccessKey", + "clientSecret", +] + + +@pytest.mark.parametrize("key", _CAMEL_CASE_REDACTION_KEYS) +def test_security_redacts_camel_case_sensitive_key_names(key): + """驼峰键名在文本与配置两个边界上必须同口径。""" + secret = "SUPERSECRETVALUE12345" + assert is_sensitive_option_key(key) is True + assert secret not in redact_sensitive_text(f"{key}={secret}") + + +def test_security_rejects_camel_case_credential_hidden_in_ordinary_option(): + """驼峰键藏进普通选项值时也必须被拦下,不能因前缀是字母而漏检。""" + with pytest.raises(ValueError, match="凭证"): + validate_secret_free_options({"note": "myApiKey=SUPERSECRETVALUE12345"}, "Provider") + + +# ── 回归:裸关键词加标点不能把普通文本判成凭证 ── + + +@pytest.mark.parametrize( + "text", + [ + "Set auth: none to disable", + "cookie: enabled", + "secret: false", + "token: 0", + "The bearer: standard", + ], +) +def test_security_keeps_benign_keyword_assignments_intact(text): + """``auth``/``cookie``/``secret`` 等裸关键词后接普通词,是文档与提示词里的 + 常见写法。判为凭证会让合法配置在边界被误拒。""" + assert redact_sensitive_text(text) == text + + +def test_security_accepts_benign_keyword_text_in_options(): + """误判不止影响日志:``find_sensitive_option_paths`` 用值是否被改写来判断 + 值里有没有凭证,误脱敏会让合法 options 直接被拒。""" + assert validate_secret_free_options( + {"instructions": "Set auth: none to disable"}, "Provider" + ) == {"instructions": "Set auth: none to disable"} + + +@pytest.mark.parametrize( + "value", + [ + "Authorization: Bearer sk-FAKE0000SHORT0000FAKE0000", + '{"api_key": "secret-value-xyz"}', + "token: sk-FAKE0000FAKE0000FAKE0000FAKE0000", + ], +) +def test_security_still_redacts_real_credentials_after_value_shape_check(value): + """加了值的形态约束后,真凭证不能跟着一起放过。""" + assert "[REDACTED]" in redact_sensitive_text(value) + + +# ── 回归:掩码尾部不能吞掉紧邻的键名 ── + + +def test_security_masked_credential_does_not_swallow_adjacent_key_name(): + """``sk-abc***token=`` 里的 ``token`` 若被掩码规则吞进匹配, + 后面的键值对就失去锚点,值会从脱敏变成明文——净漏检。""" + secret = "SECRETVALUE1234567890" + redacted = redact_sensitive_text(f"sk-abc***token={secret}") + assert secret not in redacted + # 键名保留是预期行为(脱敏的是值),关键是掩码规则没有把 ``token`` 吞掉 + # 而让后续的 ``=`` 失去锚点。 + assert redacted.count("[REDACTED]") == 2 + + +# ── 回归:urlsplit 回退分支也要检查 query 键名 ── + + +def test_security_unparseable_url_still_checks_query_key_names(): + """回退分支若只做值脱敏,``?myApiKey=`` 这类空值敏感键会在畸形 URL 下漏检, + 而同样的值在可解析 URL 下会被拦下。""" + with pytest.raises(ValueError, match="凭证"): + validate_secret_free_options({"endpoint": "https://[::1?myApiKey="}, "Provider") + + +def test_security_parseable_and_unparseable_urls_check_query_keys_equally(): + with pytest.raises(ValueError, match="凭证"): + validate_secret_free_options({"endpoint": "https://host/v1?myApiKey="}, "Provider") + + +def test_security_accepts_benign_unparseable_url(): + """严格度提升不能把无害的畸形 URL 也一并拒掉。""" + assert validate_secret_free_options({"endpoint": "https://[::1/v1/models"}, "Provider") == { + "endpoint": "https://[::1/v1/models" + } From 31f5d7358093814394d344c0e1ad871bbb014e1f Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 12:10:18 +0800 Subject: [PATCH 05/31] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Ark=20?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E8=B5=84=E6=BA=90=E9=97=A8=E7=A6=81=E3=80=81?= =?UTF-8?q?=E8=84=B1=E6=95=8F=E8=BE=B9=E7=95=8C=E4=B8=8E=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=20call=5Fid=20=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多轮审查发现以下缺陷,均已在对应提交上复现并红绿验证: Ark 渠道(volcengine.py) - ``_require_provider_resource`` 用 ``"response" in segments`` 判断,而动态 路径传的是完整路径,单数分段在四个 SDK 的资源树里都不存在;``input_items`` (打 ``/responses/{id}/input_items``)因此完全绕过门禁,chat_completions 协议下也能把请求发到远端。改为精确的子资源集合匹配。 - 动态路径只做能力检查,不执行 Facade 上的参数校验, ``resources.responses.create(...)`` 可带 instructions × caching 直接发出。 门禁钩子增加 kwargs 参数,动态路径复用 ``_validate_native_response_kwargs``。 - fail-closed 把内置工具调用项(web_search_call、mcp_call、mcp_list_tools、 reasoning 空摘要)判为「结构不符」,而这些是 SDK 输出项联合的正式成员、 属正常中间态。改为只拦真正空白的 completed 响应。 归一化(model_provider.py) - 非字符串 ``type`` 直接做集合查找,把类型错误变成 ``TypeError: unhashable type`` 崩溃。恢复 isinstance 短路。 - ``_responses_tool_call_type`` 在非 assistant 角色的兜底分支随请求体发出。 改为在消息级与工具调用项级统一剥离。 流式语义(openai_compatible.py) - ``id`` 在 delta 取 item_id、在 done 取 item.id、合并时又互相覆盖,同一轮 工具调用在 delta 与 completed 事件里得到不同的 id。统一取 call_id,与 非流式路径的 ``call_id or id`` 一致。 脱敏边界(security.py、config.py、main.py) - 词边界 ``\b`` 在中文两侧都不成立(中文属 ``\w``),国产网关的中文错误 消息会让密钥整体漏检。改用「非 ASCII 字母数字」断言。 - ``Settings()`` 在 import 期被调用,早于任何入口的 ``try``,pydantic 会把 ``input_value`` 明文交给解释器默认 handler。在 ``get_settings`` 边界重抛 已脱敏的消息,并给 ``run_cli`` 包一层启动边界。 测试 - 重写 URL-query 断言:原断言用的长值由另一条规则满足,删掉被测规则也不红。 - 替换 Ark 的 ``SimpleNamespace(output_text=...)`` 夹具为真实 SDK 模型, 该夹具此前替被测代码补上 Ark 不存在的字段,掩盖了空正文缺陷。 - 新增回归测试 40 项,红绿验证:回退源码后全部失败。 Co-Authored-By: Claude Opus 5 (1M context) --- src/providers/__base__/model_provider.py | 57 +++- src/providers/google.py | 4 +- src/providers/openai_compatible.py | 50 +++- src/providers/resources.py | 6 +- src/providers/volcengine.py | 63 ++++- src/utils/config.py | 37 ++- src/utils/security.py | 13 +- tests/providers/test_protocol_adapters.py | 8 +- tests/providers/test_sdk_capabilities.py | 288 +++++++++++++++++++- tests/providers/test_security_boundaries.py | 64 ++++- tests/retrieval_test/test_retrieval_cli.py | 48 ++++ tests/test_config.py | 35 +++ tests/test_release_scripts.py | 30 ++ 13 files changed, 658 insertions(+), 45 deletions(-) diff --git a/src/providers/__base__/model_provider.py b/src/providers/__base__/model_provider.py index 4b1fc7d..56b8d3c 100644 --- a/src/providers/__base__/model_provider.py +++ b/src/providers/__base__/model_provider.py @@ -325,6 +325,10 @@ def merge_tool_call_fragment( _CHAT_TOOL_CALL_TYPES = frozenset({"function"}) +# 归一化过程中挂在消息/工具调用上的内部标记。它们只服务于本模块的分支判断, +# 必须在交给 SDK 之前剥离:Responses 的兜底分支会原样复制消息,标记会随请求 +# 体发给服务端。 +_INTERNAL_MESSAGE_KEYS = frozenset({"_responses_tool_call_type"}) def _chat_tool_call_item( @@ -378,7 +382,15 @@ def _chat_tool_call_item( # Chat Completions 的 ``tool_calls[].type`` 只接受 ``function``;Responses 的 # ``function_call``/``custom_tool_call`` 与流式片段都要映射回该取值。 source_type = normalized.get("type") - tool_type = source_type if source_type in _CHAT_TOOL_CALL_TYPES else "function" + # 非字符串的 ``type``(例如 ``["function"]``)直接做集合查找会抛 + # ``TypeError: unhashable type``,把「类型不合法」变成崩溃。此处与 + # ``_reject_unsupported_responses_item`` 的守卫保持同一口径:先做 + # isinstance 短路,再按取值映射。 + tool_type = ( + source_type + if isinstance(source_type, str) and source_type in _CHAT_TOOL_CALL_TYPES + else "function" + ) item: dict[str, Any] = { "type": tool_type, @@ -860,10 +872,14 @@ def _responses_function_call_item( "arguments": _responses_json_text(raw_arguments, f"{location}.arguments"), } # A Chat Completions ``id`` is the call identifier, not the Responses - # output-item identifier. Do not duplicate it as ``id`` unless the caller - # supplied a distinct Responses-style ``call_id`` explicitly. - if explicit_call_id is not None and tool_call.get("id") is not None: - item["id"] = tool_call["id"] + # output-item identifier. Only emit ``id`` when it is a genuinely distinct + # output-item identifier: the caller must have supplied an explicit + # ``call_id``, and ``id`` must differ from it. Providers normalize streamed + # tool calls so that ``id == call_id``, and duplicating it here would send + # the call identifier where the SDK expects the output-item identifier. + explicit_id = tool_call.get("id") + if explicit_call_id is not None and explicit_id is not None and explicit_id != explicit_call_id: + item["id"] = explicit_id if tool_call.get("status") is not None: item["status"] = tool_call["status"] return item @@ -1013,6 +1029,26 @@ def normalize_responses_input( raise ValueError(f"{location} 的 function 消息缺少 tool_call_id。") normalized_message = dict(message) + # 归一化时会在工具调用项上挂仅用于内部判定的标记 + # (``_responses_tool_call_type``)。走 assistant.tool_calls 分支时它们 + # 被消费掉,但本兜底分支原样复制消息,标记会随请求体发给服务端。 + # 消息级与工具调用项级都要剥离。 + for internal_key in _INTERNAL_MESSAGE_KEYS: + normalized_message.pop(internal_key, None) + fallback_tool_calls = normalized_message.get("tool_calls") + if isinstance(fallback_tool_calls, Sequence) and not isinstance( + fallback_tool_calls, (str, bytes, bytearray) + ): + normalized_message["tool_calls"] = [ + { + key: value + for key, value in tool_call.items() + if key not in _INTERNAL_MESSAGE_KEYS + } + if isinstance(tool_call, Mapping) + else tool_call + for tool_call in fallback_tool_calls + ] if "content" in normalized_message: normalized_message["content"] = _responses_message_content( normalized_message["content"], @@ -1681,8 +1717,15 @@ def __init_subclass__(cls, **kwargs: Any) -> None: def supports(cls, capability: str) -> bool: return capability in cls.capabilities - def _require_provider_resource(self, method_name: str) -> None: - """为 Provider 原生资源入口提供可选的能力门禁。""" + def _require_provider_resource( + self, method_name: str, kwargs: Mapping[str, Any] | None = None + ) -> None: + """为 Provider 原生资源入口提供可选的能力门禁。 + + ``kwargs`` 是即将交给原生方法的调用参数。动态资源路径不经过 Facade, + 因此 Provider 若在 Facade 上做了参数校验,需要在这里对同一组参数 + 再校验一次,否则动态路径成为绕过参数约束的旁路。 + """ return @property diff --git a/src/providers/google.py b/src/providers/google.py index 6616f77..d7908a8 100644 --- a/src/providers/google.py +++ b/src/providers/google.py @@ -397,7 +397,9 @@ def _require_resource(client: Any, name: str, label: str) -> Any: ) return resource - def _require_provider_resource(self, method_name: str) -> None: + def _require_provider_resource( + self, method_name: str, kwargs: Mapping[str, Any] | None = None + ) -> None: """把实验性资源的版本差异转换成明确的能力错误。""" name = method_name.removeprefix("async_") resource_name: str | None = None diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index 5a5a514..0d6325a 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -593,9 +593,31 @@ def _resource_capability_for_method(method_name: str) -> str | None: return capability return None - def _require_provider_resource(self, method_name: str) -> None: + # 动态资源树路径(``OpenAIProvider.responses.create``)的末段是方法名, + # 与显式 Facade 名(``create_response``)不同形。只看显式名会让动态路径 + # 拿到 capability=None 直接放行,正是 server_verified_protocols 门禁要堵的 + # 旁路。这里按路径分段识别资源树。 + _RESPONSES_RESOURCE_SEGMENTS = frozenset({"responses", "input_items", "input_tokens"}) + _FILES_RESOURCE_SEGMENTS = frozenset({"files", "uploads"}) + + @classmethod + def _resource_capability_for_path(cls, method_name: str) -> str | None: + """从动态资源树路径推断能力名称。""" + segments = method_name.split(".") + if any(segment in cls._RESPONSES_RESOURCE_SEGMENTS for segment in segments): + return "responses" + if any(segment in cls._FILES_RESOURCE_SEGMENTS for segment in segments): + return "files" + return None + + def _require_provider_resource( + self, method_name: str, kwargs: Mapping[str, Any] | None = None + ) -> None: """在原生资源方法真正触达 SDK 前校验渠道能力。""" capability = self._resource_capability_for_method(method_name) + if capability is None: + # 显式 Facade 名匹配不上时,再按动态资源树路径判断。 + capability = self._resource_capability_for_path(method_name) if capability is None: return if capability == "responses": @@ -700,7 +722,10 @@ def _uses_official_openai_endpoint(self) -> bool: def _require_responses_resource(self, operation: str) -> None: """阻止兼容渠道把本地 SDK 资源误当成远端 Responses 能力。""" - if self._protocol != "responses": + # ``object.__new__`` 构造的实例没有 ``_protocol``;此时没有协议可校验, + # 交给下面的服务端验证门禁判断(与 ``_provider`` 缺失时的处理一致)。 + protocol = getattr(self, "_protocol", None) + if protocol is not None and protocol != "responses": raise ValueError(f"{operation} 仅适用于 responses 协议。") # 官方端点由其 SDK 契约覆盖;代理、自建网关和其它兼容渠道必须 # 显式声明已验证的服务端协议,避免本地 SDK 资源导致远端 404/405。 @@ -1883,7 +1908,12 @@ def _responses_stream_event( return StreamEvent( type="tool_call_delta", tool_call={ - "id": item_id or cls._field(event, "call_id") or metadata.get("id"), + # ``id`` 在流式路径上必须与 ``output_item.done`` 及非流式 + # 路径取同一语义(call_id)。取 ``item_id`` 会让同一轮工具 + # 调用在 delta 与 done 两个事件里得到不同的 id,且是否一致 + # 取决于事件到达顺序;调用方按 ``tool_call["id"]`` 回填 + # ``role=tool`` 时就会配不上。 + "id": call_id or item_id or metadata.get("id"), "call_id": call_id, "index": output_index if output_index is not None @@ -1928,7 +1958,13 @@ def _responses_stream_event( if argument_value is None: argument_value = metadata.get("arguments", "") tool_call: dict[str, Any] = { - "id": cls._field(item, "call_id") + # 与非流式路径(``call_id or id``)和 delta 事件保持同一 + # 语义:``id`` 是调用标识符,不是 Responses 输出项标识符。 + # 取 ``item.id`` 或 ``item_id`` 会让同一轮工具调用在 delta + # 与 done 事件里得到不同的 id,调用方按 ``tool_call["id"]`` + # 回填 ``role=tool`` 时就会配不上。上面解析出的 ``call_id`` + # 才是权威来源。 + "id": call_id or cls._field(item, "id") or cls._field(event, "item_id") or metadata.get("id"), @@ -1996,6 +2032,12 @@ def _merge_response_tool_call( value = tool_call.get(name) if value is not None and value != "": merged[name] = value + # ``id`` 必须与调用标识符一致(非流式路径用 ``call_id or id``,delta + # 事件也用 call_id)。``output_item.done`` 带的 ``id`` 是输出项 ID, + # 按上面顺序会覆盖 delta 事件写入的 call_id,使同一轮工具调用在 + # delta 与 completed 两个事件里得到不同的 id。这里统一回 call_id。 + if merged.get("call_id"): + merged["id"] = merged["call_id"] arguments = tool_call.get("arguments") if arguments is not None and arguments != "": merged["arguments"] = normalize_tool_arguments(arguments) diff --git a/src/providers/resources.py b/src/providers/resources.py index 548fb92..015c0cf 100644 --- a/src/providers/resources.py +++ b/src/providers/resources.py @@ -80,8 +80,10 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: if callable(resource_guard): # 传完整路径(``volcengine.responses.create``)而不是末段方法名: # ``files.create`` 与 ``responses.create`` 末段相同,只看方法名无法 - # 区分该走哪条能力门禁。 - resource_guard(self._path) + # 区分该走哪条能力门禁。同时把调用参数一并传入:动态路径不经过 + # Facade,Provider 在 Facade 上做的参数校验(例如 Ark 的 + # instructions × caching 互斥)必须在这里得到同等执行。 + resource_guard(self._path, safe_kwargs) # Call results are deliberately not wrapped. They are response models, # pagers, streams, or context managers rather than mutable resource # trees; preserving their SDK identity keeps normal client code intact. diff --git a/src/providers/volcengine.py b/src/providers/volcengine.py index 0af6291..f76dd48 100644 --- a/src/providers/volcengine.py +++ b/src/providers/volcengine.py @@ -56,6 +56,32 @@ def _load_ark_clients() -> tuple[type[Any], type[Any]]: return Ark, AsyncArk +# Responses 的 output 里,除 message 与 function_call 外还有内置工具的调用项。 +# 这些项既无正文也不是 function_call,但都是 SDK ``ResponseOutputItem`` 联合的 +# 正式成员,属正常中间态而非「结构不符」。 +_ARK_BUILTIN_TOOL_ITEM_TYPES = frozenset( + { + "web_search_call", + "mcp_call", + "mcp_list_tools", + "mcp_approval_request", + "knowledge_search_call", + "doubao_app_call", + # reasoning 项在模型本轮无推理摘要时 ``summary`` 为空列表,但项本身 + # 合法(SDK 已把它作为独立 item 类型返回)。 + "reasoning", + } +) + + +def _ark_responses_has_builtin_tool_items(response: Any) -> bool: + """判断响应里是否含 Ark 内置工具的调用项。""" + for item in field(response, "output", []) or []: + if field(item, "type") in _ARK_BUILTIN_TOOL_ITEM_TYPES: + return True + return False + + def _ark_responses_output_text(response: Any) -> str: """从 Responses 的 ``output[].content[]`` 提取助手正文。 @@ -607,16 +633,41 @@ def _normalize_verified_protocols(cls, configured: Any) -> frozenset[str]: normalized.add(protocol) return frozenset(normalized) - def _require_provider_resource(self, method_name: str) -> None: - """在原生资源方法触达 SDK 前校验 Ark 渠道能力。 + # Responses 资源树下的子资源。它们的路径不含 ``responses`` 分段 + # (``volcengine.input_items.list`` 打的是 ``/responses/{id}/input_items``), + # 只看分段会把它们漏掉;但 ``files.create`` 这类同名无关调用必须放行, + # 因此用精确集合而不是子串匹配。 + _ARK_RESPONSES_SUBRESOURCES = frozenset( + { + "input_items", + "input_tokens", + "output_items", + } + ) + + def _require_provider_resource( + self, method_name: str, kwargs: Mapping[str, Any] | None = None + ) -> None: + """在原生资源方法触达 SDK 前校验 Ark 渠道能力与参数约束。 ``method_name`` 可能是显式方法名(``create_response``),也可能是动态 资源树路径(``volcengine.responses.create``)。按路径分段判断,避免把 ``files.create`` 这类同名但无关的调用一并拦下。 + + 动态路径不经过 Facade,因此 Facade 上的参数校验必须在这里对同一组 + ``kwargs`` 再执行一次,否则 ``resources.responses.create(...)`` 可以 + 带着 ``instructions`` 与 ``caching={"type": "enabled"}`` 直接发出。 """ segments = method_name.split(".") - if "responses" in segments or method_name.endswith("_response") or "response" in segments: + if ( + "responses" in segments + or method_name.endswith("_response") + or "response" in segments + or any(segment in self._ARK_RESPONSES_SUBRESOURCES for segment in segments) + ): self._require_responses_resource(method_name) + if kwargs and segments[-1] in {"create", "generate"}: + self._validate_native_response_kwargs(kwargs) def _require_responses_resource(self, operation: str) -> None: """阻止未验证的 Ark Responses 请求到达远端。""" @@ -1436,11 +1487,17 @@ def _extract_result(response: Any) -> CompletionResult: and not refusal and not reasoning and field(response, "status") == "completed" + and not _ark_responses_has_builtin_tool_items(response) ): # 项目规则禁止用占位结果掩盖失败。Responses 响应标记为 completed # 却既无正文、无工具调用、也无拒答与推理,说明响应结构与预期不符 # (例如 SDK 改了字段形状)。此时静默返回空文本会让上层拿到空串后 # 继续,最终表现为难以定位的「空结果」错误,因此在边界显式失败。 + # + # 但内置工具(web_search_call、mcp_call、mcp_list_tools 等)的 + # 调用项既不是 function_call 也没有正文,而它们是 Ark 内置工具 + # 的正常中间态——没有工具调用就不可能有后续轮次,调用方需要读 + # output 才能继续。把它们判为「结构不符」会让这类响应无法处理。 raise RuntimeError( "Ark Responses 响应已完成但未包含任何正文、工具调用或拒答内容;" "请检查响应结构与 SDK 版本是否匹配。" diff --git a/src/utils/config.py b/src/utils/config.py index c1324ae..ace32a8 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -7,7 +7,14 @@ from pathlib import Path from typing import Any, ClassVar, cast -from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator +from pydantic import ( + BaseModel, + Field, + ValidationError, + ValidationInfo, + field_validator, + model_validator, +) from pydantic.fields import FieldInfo from pydantic_settings import ( BaseSettings, @@ -15,7 +22,7 @@ SettingsConfigDict, ) -from src.utils.security import validate_secret_free_options +from src.utils.security import redact_sensitive_text, validate_secret_free_options # ================================================================= # 1. 基础定义 (DEFINITIONS) @@ -644,8 +651,32 @@ def get_settings() -> Settings: """ 获取 Settings 实例的单例。 加载顺序由 settings_customise_sources 定义。 + + 配置校验失败时重抛一个已脱敏的异常。``Settings()`` 在 import 期就会被调用 + (``log_manager.get_module_logger``),早于任何入口的 ``try``,pydantic 的 + 默认渲染会把 ``input_value`` 明文交给解释器默认 handler——而 ``options`` 是 + 文档指定的扩展入口,把 ``api_key`` 放进去恰好就是被校验拒绝的那类错误。 + """ + try: + return Settings() + except ValidationError as exc: + raise ValueError(_redacted_settings_error(exc)) from None + + +def _redacted_settings_error(exc: ValidationError) -> str: + """把配置校验失败整理成一行脱敏消息。 + + 直接重抛 ``ValidationError`` 会让 pydantic 再包一层,原始报告的 + ``input_value`` 会以嵌套形式重复出现;这里逐条取 ``loc`` 与 ``msg`` + 重建,只保留定位信息和校验原因,并把值整体交给 ``redact_sensitive_text``。 """ - return Settings() + lines = [] + for error in exc.errors(): + location = ".".join(str(part) for part in error.get("loc", ())) + message = str(error.get("msg", "校验失败")) + value = redact_sensitive_text(str(error.get("input"))) + lines.append(f"{location}: {message} (input={value})" if location else f"{message}") + return "配置校验失败 - " + "; ".join(lines) if lines else "配置校验失败" # 导出 get_settings 函数,供其他模块在需要时调用 diff --git a/src/utils/security.py b/src/utils/security.py index 03ba6b5..90356f2 100644 --- a/src/utils/security.py +++ b/src/utils/security.py @@ -90,7 +90,10 @@ # subject to credential scanning. _SAFE_RESOURCE_PARAMETER_CONTAINERS = frozenset({"query", "params"}) -_BEARER_TEXT_RE = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+") +# 用「非 ASCII 字母数字」而不是 ``\b`` 作边界:中文属 ``\w``,``\b`` 在 +# ``密钥sk-...`` 两侧都不成立,而国产网关(SiliconFlow、Ark、DashScope)的 +# 错误消息正是中文,密钥会整串落进日志。 +_BEARER_TEXT_RE = re.compile(r"(?i)(?`` # 里的 ``token`` 若被吞进掩码匹配,后面的 ``token=`` 就失去锚点, # 值会从脱敏变成明文(净漏检)。断言在键名之前停下,让键值规则处理它。 - r"(?i)\b(?:sk|rk|sess)-[A-Za-z0-9_.-]{2,}[*\u2026.]{2,}" + r"(?i)(? str: diff --git a/tests/providers/test_protocol_adapters.py b/tests/providers/test_protocol_adapters.py index 001b6fd..635d4c5 100644 --- a/tests/providers/test_protocol_adapters.py +++ b/tests/providers/test_protocol_adapters.py @@ -1906,8 +1906,12 @@ def test_openai_responses_custom_tool_stream_keeps_added_metadata_and_raw_input( metadata, ) + # ``id`` 统一取调用标识符(call_id),与非流式路径及合并结果一致: + # ``item_id`` 是 Responses 的输出项 ID,取它会让同一轮工具调用在 delta 与 + # completed 事件里得到不同的 id,调用方按 ``tool_call["id"]`` 回填 + # ``role=tool`` 时就会配不上。 assert delta is not None and delta.tool_call == { - "id": "item-1", + "id": "call-1", "call_id": "call-1", "index": 1, "type": "custom_tool_call", @@ -1915,7 +1919,7 @@ def test_openai_responses_custom_tool_stream_keeps_added_metadata_and_raw_input( "arguments": "echo ", } assert completed is not None and completed.tool_call == { - "id": "item-1", + "id": "call-1", "index": 1, "type": "custom_tool_call", "name": "run_command", diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index a2d188c..399c0a8 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -2455,18 +2455,27 @@ def test_ark_non_stream_result_preserves_chat_reasoning_content(): def test_ark_responses_result_does_not_replace_answer_with_reasoning_text(): - response = SimpleNamespace( - output_text="最终答案", - output=[ - SimpleNamespace( - type="reasoning", - summary=[SimpleNamespace(type="summary_text", text="思考过程")], - ) - ], - choices=[], - usage=None, - status="completed", - id="resp-1", + """正文与 reasoning 必须分开取。 + + 此前这里用 ``SimpleNamespace(output_text=...)`` 伪造响应,但 Ark 的 + ``Response`` 没有该字段——夹具替被测代码补上了它,掩盖了「非流式入口 + 读不到正文」的缺陷。改用真实 SDK 模型构造。 + """ + response = _ark_response( + [ + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "最终答案", "annotations": []}], + }, + { + "type": "reasoning", + "id": "r1", + "summary": [{"type": "summary_text", "text": "思考过程"}], + }, + ] ) result = VolcengineProvider._extract_result(response) @@ -6522,3 +6531,258 @@ def test_responses_function_call_output_pairs_with_preserved_call_id(): ) assert converted[0]["call_id"] == converted[1]["call_id"] == "call_abc" + + +# ── 回归:动态资源路径的门禁与参数校验 ── + + +def _ark_provider_with_recording_client(protocol="chat_completions", verified=()): + """构造带记录型客户端的 Ark 资源 Facade,返回 (resources, 捕获列表)。 + + 用 ``object.__new__`` 绕过 ``__init__``:本组测试只关心门禁与参数校验的 + 路径判断,不需要真实凭证,也不该依赖环境变量。 + """ + from src.providers.volcengine import ArkResources + + captured: list[tuple[str, dict]] = [] + provider = object.__new__(VolcengineProvider) + provider._provider = "volcengine" + provider._model_name = "ark-model" + provider._protocol = protocol + provider._server_verified_protocols = frozenset(verified) + provider._options = {} + object.__setattr__( + provider, + "_client", + SimpleNamespace( + responses=SimpleNamespace( + create=lambda **kwargs: captured.append(("responses.create", kwargs)) or "remote", + ), + input_items=SimpleNamespace( + list=lambda *args, **kwargs: captured.append(("input_items.list", kwargs)) or [], + ), + files=SimpleNamespace( + create=lambda **kwargs: captured.append(("files.create", kwargs)) or "local" + ), + ), + ) + return ArkResources(provider), captured + + +def test_ark_dynamic_input_items_resource_enforces_responses_gate(): + """``input_items`` 是 Responses 的子资源,路径分段不含 ``responses`` + (``volcengine.input_items.list`` 打的是 ``/responses/{id}/input_items``)。 + 只看分段会让它在未验证 responses 的渠道上把请求发出去。 + """ + resources, captured = _ark_provider_with_recording_client(protocol="chat_completions") + + with pytest.raises(ValueError, match="responses 协议"): + resources.input_items.list("resp_1") + + assert captured == [] + + +def test_ark_dynamic_files_resource_is_not_gated_as_responses(): + """门禁不能误伤同名无关的资源:``files.create`` 与 Responses 无关。""" + resources, captured = _ark_provider_with_recording_client(protocol="chat_completions") + + resources.files.create(file=("a.txt", b"x"), purpose="assistants") + + assert [name for name, _ in captured] == ["files.create"] + + +def test_ark_dynamic_responses_create_enforces_parameter_validation(): + """动态路径不经过 Facade,Facade 上的参数校验必须同等执行,否则 + ``resources.responses.create(...)`` 会带着互斥参数直接发出。""" + resources, captured = _ark_provider_with_recording_client( + protocol="responses", verified=["responses"] + ) + + with pytest.raises(ValueError, match="互斥"): + resources.responses.create( + model="ark-model", input="x", instructions="sys", caching={"type": "enabled"} + ) + + assert captured == [] + + +def test_ark_dynamic_responses_create_requires_model(): + """原生调用必须显式提供 model,动态路径同样要拦。""" + resources, captured = _ark_provider_with_recording_client( + protocol="responses", verified=["responses"] + ) + + with pytest.raises(ValueError, match="model"): + resources.responses.create(input="x") + + assert captured == [] + + +def test_ark_dynamic_responses_create_allows_valid_request(): + """补了参数校验后,合法调用不能被误拦。""" + resources, captured = _ark_provider_with_recording_client( + protocol="responses", verified=["responses"] + ) + + resources.responses.create(model="ark-model", input="x") + + assert [name for name, _ in captured] == ["responses.create"] + + +# ── 回归:内置工具调用项是合法中间态,不能被 fail-closed 判为结构不符 ── + + +@pytest.mark.parametrize( + "item", + [ + { + "type": "web_search_call", + "id": "w1", + "action": {"type": "search", "query": "q"}, + "status": "completed", + }, + {"type": "mcp_call", "id": "c1", "name": "n", "arguments": "{}", "server_label": "s"}, + {"type": "mcp_list_tools", "id": "m1", "server_label": "s", "tools": []}, + {"type": "reasoning", "id": "r1", "summary": []}, + ], +) +def test_ark_builtin_tool_items_are_not_treated_as_malformed(item): + """内置工具(web_search_call、mcp_call 等)的调用项既无正文也不是 + function_call,但都是 SDK 输出项联合的正式成员,属正常中间态——没有工具 + 调用就不可能有后续轮次。判为「结构不符」会让这类响应无法处理。 + """ + response = _ark_response([item]) + + result = VolcengineProvider._extract_result(response) + + assert result.text == "" + + +def test_ark_empty_completed_response_still_fails_loudly(): + """放宽内置工具项后,真正空白的 completed 响应仍必须显式失败。""" + response = _ark_response([]) + + with pytest.raises(RuntimeError, match="未包含任何正文"): + VolcengineProvider._extract_result(response) + + +# ── 回归:非字符串 tool type 不能把「类型不合法」变成崩溃 ── + + +@pytest.mark.parametrize("bad_type", [["function"], {"a": 1}, ["custom_tool_call"]]) +def test_chat_tool_call_item_accepts_non_string_type_without_crashing(bad_type): + """``source_type in _CHAT_TOOL_CALL_TYPES`` 对 unhashable 值抛 + ``TypeError: unhashable type``,把可诊断的类型错误变成崩溃。""" + from src.providers.__base__.model_provider import _chat_tool_call_item + + item = _chat_tool_call_item( + {"id": "c1", "type": bad_type, "name": "n", "arguments": "{}"}, "loc" + ) + + assert item["type"] == "function" + + +# ── 回归:内部标记不能随请求体发出 ── + + +@pytest.mark.parametrize("role", [None, "user", "system", "developer"]) +def test_responses_input_strips_internal_markers_for_non_assistant_roles(role): + """``_responses_tool_call_type`` 是内部判定用的标记。assistant.tool_calls + 分支会消费它,但兜底分支原样复制消息,标记会随请求体发给服务端。 + """ + message = { + "content": "go", + "tool_calls": [{"id": "c1", "type": "custom_tool_call", "name": "n", "input": "raw"}], + } + if role is not None: + message["role"] = role + + converted = normalize_responses_input(None, None, [message], provider="ark") + + assert "_responses_tool_call_type" not in json.dumps(converted) + + +def test_responses_input_keeps_custom_tool_semantics_for_assistant(): + """剥离内部标记不能影响 assistant 路径的 custom_tool_call 语义。""" + converted = normalize_responses_input( + None, + None, + [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "c1", "type": "custom_tool_call", "name": "n", "input": "raw text"} + ], + } + ], + provider="ark", + ) + + assert converted[0]["type"] == "custom_tool_call" + assert converted[0]["input"] == "raw text" + assert "_responses_tool_call_type" not in json.dumps(converted) + + +# ── 回归:流式工具调用的 id 语义必须与非流式路径一致 ── + + +def test_responses_stream_tool_call_id_is_stable_across_delta_and_done(): + """``id`` 必须是调用标识符,与非流式路径(``call_id or id``)一致。 + + 取 ``item_id``(输出项 ID)会让同一轮工具调用在 delta 与 done 事件里得到 + 不同的 id,调用方按 ``tool_call["id"]`` 回填 ``role=tool`` 时就会配不上。 + """ + metadata: dict = {} + OpenAICompatibleProvider._responses_stream_event( + SimpleNamespace( + type="response.output_item.added", + output_index=1, + item=SimpleNamespace( + type="custom_tool_call", id="item-1", call_id="call-1", name="run", input="" + ), + ), + metadata, + ) + + delta = OpenAICompatibleProvider._responses_stream_event( + SimpleNamespace( + type="response.custom_tool_call_input.delta", + item_id="item-1", + output_index=1, + delta="echo ", + ), + metadata, + ) + done = OpenAICompatibleProvider._responses_stream_event( + SimpleNamespace( + type="response.custom_tool_call_input.done", + item_id="item-1", + output_index=1, + input="hello world", + ), + metadata, + ) + + assert delta is not None and done is not None + assert delta.tool_call["id"] == done.tool_call["id"] == "call-1" + + +def test_responses_function_call_item_does_not_duplicate_call_id_as_output_id(): + """归一化后的流式形状里 ``id == call_id``。把它当输出项 ID 发出,等于把 + 调用标识符填进了 SDK 期望输出项标识符的位置。""" + from src.providers.__base__.model_provider import _responses_function_call_item + + item = _responses_function_call_item( + { + "id": "call-1", + "call_id": "call-1", + "type": "function_call", + "name": "n", + "arguments": "{}", + }, + "loc", + ) + + assert item["call_id"] == "call-1" + assert "id" not in item diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index d48660b..33ee1a1 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -100,9 +100,16 @@ def test_security_redacts_every_sensitive_key_name_in_text(key): @pytest.mark.parametrize("key", _TEXT_REDACTION_KEYS) def test_security_redacts_sensitive_key_names_in_url_query(key): - """URL query 形态同样要覆盖,不能只处理 key=value。""" - secret = "SUPERSECRETVALUE12345" - assert secret not in redact_sensitive_text(f"https://example.invalid/x?{key}={secret}") + """URL query 形态同样要覆盖,不能只处理 key=value。 + + 值必须**短到只有 ``_URL_QUERY_SECRET_RE`` 能覆盖**:``_KEY_VALUE_TEXT_RE`` + 的空白/标点形态要求值至少 12 字符且含数字或符号,用一个长值会让这条断言 + 由另一条规则满足——删掉 URL query 规则测试照样通过,即测错了对象。 + """ + for secret in ("x", "0", "none"): + redacted = redact_sensitive_text(f"https://example.invalid/x?{key}={secret}") + # 断言精确形式:值必须被替换掉,且替换位置就在 ``?key=`` 之后。 + assert redacted == f"https://example.invalid/x?{key}=[REDACTED]", (key, secret, redacted) @pytest.mark.parametrize( @@ -177,16 +184,18 @@ def test_safe_exception_text_handles_empty_and_long_messages(): def test_user_facing_error_paths_do_not_print_raw_exceptions(): - """``retrieval_test`` 与顶层入口曾把裸异常交给 console/logger,凭证会 - 原样落到终端与日志;``chat`` 路径已做脱敏,三者必须一致。""" + """面向用户的错误出口必须经 ``safe_exception_text``。 + + 这里只做「是否引用」的存在性检查,作为粗筛;真正的行为断言在 + ``tests/retrieval_test/test_retrieval_cli.py``——读源码做子串匹配拦不住 + 回归(``str(exc)``、f-string 拼接等写法都能绕过),也覆盖不到新出口。 + """ import pathlib root = pathlib.Path(__file__).resolve().parents[2] for relative in ("src/retrieval_test/core.py", "main.py", "src/chat/core.py"): source = (root / relative).read_text(encoding="utf-8") assert "safe_exception_text" in source, relative - assert 'console.print(f"[red]召回测试出错: {exc}' not in source, relative - assert '{e}")' not in source, relative # ── 回归:文本键名口径必须与 is_sensitive_option_key 一致 ── @@ -292,3 +301,44 @@ def test_security_accepts_benign_unparseable_url(): assert validate_secret_free_options({"endpoint": "https://[::1/v1/models"}, "Provider") == { "endpoint": "https://[::1/v1/models" } + + +# ── 回归:CJK 紧邻时词边界失效导致密钥整体漏检 ── + + +# 项目主对接 SiliconFlow、Ark/火山、DashScope,这些网关的错误体是中文; +# 而中文属 ``\w``,``\b`` 在 ``密钥sk-...`` 两侧都不成立。 +_CJK_ADJACENT_SECRETS = [ + "sk-proj-FAKE0000FAKE0000FAKE0000FAKE0000", + "AIzaFAKE0000FAKE0000FAKE0000FAKE0000", +] + + +@pytest.mark.parametrize("secret", _CJK_ADJACENT_SECRETS) +@pytest.mark.parametrize( + "template", + ["请求失败,密钥{secret}无效", "鉴权失败,请检查{secret}是否正确", "无效的令牌{secret}"], +) +def test_security_redacts_secrets_adjacent_to_cjk(template, secret): + """中文错误消息紧贴密钥时必须照常脱敏。""" + redacted = redact_sensitive_text(template.format(secret=secret)) + assert secret not in redacted + assert "[REDACTED]" in redacted + + +@pytest.mark.parametrize("secret", _CJK_ADJACENT_SECRETS) +def test_safe_exception_text_redacts_secrets_adjacent_to_cjk(secret): + """面向用户的异常出口同样要覆盖中文消息。""" + from src.utils.security import safe_exception_text + + rendered = safe_exception_text(RuntimeError(f"服务端返回错误密钥为{secret}。")) + assert secret not in rendered + + +@pytest.mark.parametrize( + "text", + ["disk-abcdefghijklmnop", "risk-analysis-report", "task = value", "mask=none"], +) +def test_security_cjk_boundary_change_does_not_flag_ordinary_text(text): + """放宽词边界不能把普通连字符单词误判成凭证。""" + assert redact_sensitive_text(text) == text diff --git a/tests/retrieval_test/test_retrieval_cli.py b/tests/retrieval_test/test_retrieval_cli.py index bfafe91..07a60ad 100644 --- a/tests/retrieval_test/test_retrieval_cli.py +++ b/tests/retrieval_test/test_retrieval_cli.py @@ -55,3 +55,51 @@ async def retrieve(self, *_args, **_kwargs): asyncio.run(run_retrieval_test_async()) assert any("召回测试出错" in message for message in printed_messages) + + +def test_retrieval_failure_output_redacts_credentials(monkeypatch): + """召回测试的错误出口曾把裸异常交给 console/logger,凭证会原样落到 + 终端与日志;``chat`` 路径已做脱敏,两者必须一致。 + + 断言行为而不是源码文本:读源码做子串匹配既拦不住回归,也无法覆盖 + 拼接、``str(exc)`` 等其它写法。 + """ + secret = "sk-proj-FAKE0000FAKE0000FAKE0000FAKE0000" + printed_messages = [] + logged_messages = [] + + class FailingRetrievalService: + def __init__(self, *_args, **_kwargs): + return None + + async def retrieve(self, *_args, **_kwargs): + raise RuntimeError(f"服务端返回错误密钥为{secret}。") + + monkeypatch.setattr( + "src.retrieval_test.core.PromptSession", + lambda: FakePromptSession(["query", "/quit"]), + ) + monkeypatch.setattr("src.retrieval_test.core.ExcelLogger", lambda: None) + monkeypatch.setattr("src.retrieval_test.core.RetrievalService", FailingRetrievalService) + monkeypatch.setattr( + "src.retrieval_test.core.EmbeddingService", lambda *_args, **_kwargs: object() + ) + monkeypatch.setattr( + "src.retrieval_test.core.VectorStoreFactory.get_default_vector_store", lambda: object() + ) + monkeypatch.setattr( + "src.retrieval_test.core.console.print", + lambda message, *args, **kwargs: printed_messages.append(str(message)), + ) + monkeypatch.setattr( + "src.retrieval_test.core.logger.error", + lambda message, *args, **kwargs: logged_messages.append( + message % args if args else message + ), + ) + + asyncio.run(run_retrieval_test_async()) + + combined = " ".join(printed_messages + logged_messages) + assert secret not in combined, combined + assert "REDACTED" in combined, combined diff --git a/tests/test_config.py b/tests/test_config.py index 0098297..6b53bbb 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -402,3 +402,38 @@ def test_settings_accepts_current_and_custom_base_urls(): warnings_module.simplefilter("always") Settings.model_validate({"qwen_base_url": qwen, "volc_base_url": volc}) assert not [w for w in caught if "base_url" in str(w.message)] + + +# ── 回归:配置校验失败不能把凭证交给解释器默认 handler ── + + +def test_get_settings_redacts_credential_in_validation_error(isolated_config): + """``Settings()`` 在 import 期就被调用(``log_manager.get_module_logger``), + 早于任何入口的 ``try``。pydantic 的默认渲染会把 ``input_value`` 明文交给 + 解释器默认 handler,而 ``options`` 是文档指定的扩展入口——把 ``api_key`` + 放进去恰好就是被校验拒绝的那类错误,即校验越严格越容易走到这条路径。 + """ + secret = "sk-proj-FAKE0000FAKE0000FAKE0000FAKE0000" + (isolated_config / "config.toml").write_text( + MOCK_TOML_CONTENT + f'\n[llm_configurations.leaky]\nprovider = "openai"\nmodel_name = "m"\n' + f'options = {{ api_key = "{secret}" }}\n', + encoding="utf-8", + ) + + with pytest.raises(ValueError) as excinfo: + get_settings() + + message = str(excinfo.value) + assert secret not in message + assert "llm_configurations.leaky.options" in message + assert "REDACTED" in message + + +def test_get_settings_reports_non_credential_validation_errors(isolated_config): + """脱敏边界不能把普通配置错误也变成不可读的嵌套报告。""" + (isolated_config / "config.toml").write_text( + MOCK_TOML_CONTENT + "\nchat_top_k = 0\n", encoding="utf-8" + ) + + with pytest.raises(ValueError, match="chat_top_k"): + get_settings() diff --git a/tests/test_release_scripts.py b/tests/test_release_scripts.py index 980ae1c..9e98253 100644 --- a/tests/test_release_scripts.py +++ b/tests/test_release_scripts.py @@ -3,7 +3,9 @@ import pytest from scripts.build_binary_release import ( + PACKAGE_FILES, prepare_runtime_layout, + stage_bundle, validate_bundle, validate_target_environment, ) @@ -68,3 +70,31 @@ def test_validate_target_environment_accepts_matching_host(target, system, machi def test_validate_target_environment_rejects_cross_architecture(): with pytest.raises(RuntimeError, match="不执行跨平台交叉编译"): validate_target_environment("macos-arm64", system="Darwin", machine="x86_64") + + +def test_stage_bundle_copies_package_files_including_subdirectories(monkeypatch, tmp_path): + """PACKAGE_FILES 含子目录路径(licenses/APACHE-2.0.txt)。 + + ``shutil.copy2`` 不会创建父目录,缺了 mkdir 会在打包时抛 + FileNotFoundError;而这一步只在发布时才跑到,本地测试很容易漏掉。 + """ + dist_root = tmp_path / "dist" / "PyRAG-Kit" + dist_root.mkdir(parents=True) + (dist_root / "PyRAG-Kit").write_bytes(b"binary") + artifact_root = tmp_path / "release_artifacts" + + monkeypatch.setattr("scripts.build_binary_release.DIST_ROOT", tmp_path / "dist") + monkeypatch.setattr("scripts.build_binary_release.ARTIFACT_ROOT", artifact_root) + monkeypatch.setattr("scripts.build_binary_release.PROJECT_ROOT", tmp_path) + for relative in PACKAGE_FILES: + source = tmp_path / relative + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("content", encoding="utf-8") + + bundle_root = stage_bundle("linux-x64", "1.4.0") + + assert bundle_root.name == "PyRAG-Kit-1.4.0-linux-x64" + for relative in PACKAGE_FILES: + assert (bundle_root / relative).is_file(), relative + # 子目录路径必须真的落在子目录里,而不是被拍平。 + assert (bundle_root / "licenses" / "APACHE-2.0.txt").parent.name == "licenses" From e80f879178f1b03cc357b579bfa6b7e9af1ff50c Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 12:23:08 +0800 Subject: [PATCH 06/31] =?UTF-8?q?test:=20=E8=A1=A5=E5=BC=BA=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95=E7=9A=84=E5=8C=BA=E5=88=86=E5=BA=A6?= =?UTF-8?q?=E4=B8=8E=E8=A6=86=E7=9B=96=E7=BC=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查发现三处测试未能守住它声称保护的回归: - ``test_security_redacts_sensitive_key_names_in_url_query`` 恒真:断言用的 长值由 ``_KEY_VALUE_TEXT_RE`` 满足,删掉整条 URL query 规则也全绿。改用 只有该规则能覆盖的短值(``x``/``0``/``none``)并断言精确替换形式。 - ``test_ark_responses_result_does_not_replace_answer_with_reasoning_text`` 用 ``SimpleNamespace(output_text=...)`` 伪造响应,而 Ark 的 ``Response`` 没有该字段——夹具替被测代码补上了它,掩盖了空正文缺陷。改用真实 SDK 模型。 - 词表一致性不变量无人看守:往 ``SENSITIVE_OPTION_KEYS`` 加真凭证键时, 文本词表不会自动跟上。改为从该词表派生断言,并显式声明有意排除的 8 个 连接类键名。 另补一条驱动公开入口的集成测试:原缺陷是 complete/acomplete/invoke/ ainvoke 四个非流式入口全部返回空文本,此前只钉住私有 ``_extract_result``, 入口绕开助手或丢弃返回值时不会变红。 新增断言均已红绿验证。 Co-Authored-By: Claude Opus 5 (1M context) --- tests/providers/test_sdk_capabilities.py | 59 +++++++++++++++++++++ tests/providers/test_security_boundaries.py | 47 ++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index 399c0a8..0f192e1 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -6786,3 +6786,62 @@ def test_responses_function_call_item_does_not_duplicate_call_id_as_output_id(): assert item["call_id"] == "call-1" assert "id" not in item + + +def test_ark_responses_public_entrypoints_return_text_from_output_content(): + """驱动公开入口,而不只是私有 ``_extract_result``。 + + 缺陷原文是「complete / acomplete / invoke(stream=False) / + ainvoke(stream=False) 全部返回空文本」。只钉住私有助手的话,将来某个入口 + 绕开 ``_extract_result`` 或丢弃其返回值,测试不会发现。 + """ + import asyncio + + response = _ark_response( + [ + { + "type": "message", + "id": "msg_1", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "这是真实回答。", "annotations": []}], + } + ] + ) + + def make_provider(): + provider = object.__new__(VolcengineProvider) + provider._provider = "volcengine" + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._server_verified_protocols = frozenset({"responses"}) + provider._options = {} + + async def _acreate(**_kwargs): + return response + + object.__setattr__( + provider, + "_client", + SimpleNamespace(responses=SimpleNamespace(create=lambda **_kwargs: response)), + ) + object.__setattr__( + provider, + "_aclient", + SimpleNamespace(responses=SimpleNamespace(create=_acreate)), + ) + return provider + + # 四个非流式公开入口。``invoke``/``ainvoke`` 的 ``stream=False`` 走 + # ``_extract_result`` 分支,正是此前返回空文本的那条路径。 + assert make_provider().complete(CompletionRequest(prompt="hi")).text == "这是真实回答。" + assert list(make_provider().invoke(prompt="hi", stream=False)) == ["这是真实回答。"] + + async def _collect_ainvoke(): + return [chunk async for chunk in make_provider().ainvoke(prompt="hi", stream=False)] + + assert ( + asyncio.run(make_provider().acomplete(CompletionRequest(prompt="hi"))).text + == "这是真实回答。" + ) + assert asyncio.run(_collect_ainvoke()) == ["这是真实回答。"] diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index 33ee1a1..64c7517 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -342,3 +342,50 @@ def test_safe_exception_text_redacts_secrets_adjacent_to_cjk(secret): def test_security_cjk_boundary_change_does_not_flag_ordinary_text(text): """放宽词边界不能把普通连字符单词误判成凭证。""" assert redact_sensitive_text(text) == text + + +# ── 不变量:文本脱敏词表与配置边界词表的差异必须是有意为之 ── + + +# 这些键在配置边界被禁止,是因为它们会覆盖请求边界(连接类),本身不是凭证。 +# 纳入文本识别会让普通配置值被误判为凭证,因此有意排除。 +_CONNECTION_ONLY_KEYS = frozenset( + { + "aiohttpclient", + "asyncclientargs", + "baseurl", + "clientargs", + "httpclient", + "httpxasyncclient", + "httpxclient", + "websocketbaseurl", + } +) + + +def test_text_redaction_covers_every_credential_key_except_connection_only(): + """配置边界判为敏感的键名,在文本里也必须脱敏——除有意排除的连接类键名。 + + 这条不变量此前无人看守:往 ``SENSITIVE_OPTION_KEYS`` 加真凭证键时, + 文本词表不会自动跟上,日志就会明文输出该键的值。 + """ + from src.utils.security import SENSITIVE_OPTION_KEYS + + secret = "SUPERSECRETVALUE12345" + missing = { + key + for key in SENSITIVE_OPTION_KEYS + if key not in _CONNECTION_ONLY_KEYS and secret in redact_sensitive_text(f"{key}={secret}") + } + + assert missing == set() + + +def test_connection_only_keys_are_deliberately_excluded_from_text_redaction(): + """锁定有意排除的那一侧:这些键名出现时不能被误判为凭证。""" + from src.utils.security import SENSITIVE_OPTION_KEYS + + for key in _CONNECTION_ONLY_KEYS: + assert key in SENSITIVE_OPTION_KEYS, key + text = f"{key}=https://example.invalid/v1" + assert redact_sensitive_text(text) == text, key From 9ba7d70ff1ef5db6aa0026b07962c84b9eb50514 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 12:23:51 +0800 Subject: [PATCH 07/31] =?UTF-8?q?fix:=20=E7=BB=99=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E9=98=B6=E6=AE=B5=E5=8A=A0=E5=BC=82=E5=B8=B8=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``display_banner`` 与 ``initialize_dependencies`` 在 ``main()`` 的 ``try`` 之前执行,这两步里的配置错误会以裸 traceback 落到终端。虽然 ``get_settings`` 已对校验失败做脱敏,traceback 本身仍会打印,且其它启动 期异常(依赖初始化、路径解析)完全没有兜底。 在 ``run_cli`` 包一层:失败时打印脱敏消息并返回退出码 1。 Co-Authored-By: Claude Opus 5 (1M context) --- main.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/main.py b/main.py index de2eecf..f7d3b5a 100644 --- a/main.py +++ b/main.py @@ -167,11 +167,20 @@ def run_smoke_test() -> int: def run_cli(argv: list[str] | None = None) -> int: - """解析启动参数并执行对应入口。""" + """解析启动参数并执行对应入口。 + + 这里包住整个启动阶段:``display_banner`` 与 ``initialize_dependencies`` + 在 ``main()`` 的 ``try`` 之前执行,配置错误会以裸 traceback 落到终端。 + import 期的失败由 ``get_settings`` 的边界负责(此处尚未执行)。 + """ args = list(sys.argv[1:] if argv is None else argv) - if "--smoke-test" in args: - return run_smoke_test() - main() + try: + if "--smoke-test" in args: + return run_smoke_test() + main() + except Exception as exc: # noqa: BLE001 - top-level CLI boundary reports failures + console.print(f"[bold red]启动失败:[/bold red] {safe_exception_text(exc)}") + return 1 return 0 From 97d750d74c8466e00d2e14f72fc565a3d14b6986 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 12:24:33 +0800 Subject: [PATCH 08/31] =?UTF-8?q?fix:=20=E9=81=BF=E5=85=8D=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=84=9A=E6=9C=AC=E7=9A=84=E5=BE=AA=E7=8E=AF=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E9=81=AE=E8=94=BD=E5=B9=B3=E5=8F=B0=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``stage_bundle`` 用 ``target`` 作为循环变量名,遮蔽了同名的函数参数(平台 标识,如 ``macos-arm64``)。当前循环之后没有再读 ``target``,因此尚未触发 故障,但这是一颗哑弹:后续在该循环后追加任何使用平台名的逻辑都会拿到 ``Path`` 而不是字符串。改用 ``destination``。 Co-Authored-By: Claude Opus 5 (1M context) --- scripts/build_binary_release.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/build_binary_release.py b/scripts/build_binary_release.py index 05f11ea..9424ae3 100755 --- a/scripts/build_binary_release.py +++ b/scripts/build_binary_release.py @@ -167,11 +167,13 @@ def stage_bundle(target: str, version: str) -> Path: shutil.copytree(app_source, app_target) for relative_file in PACKAGE_FILES: - target = bundle_root / relative_file + # 不要复用 ``target``:它是本函数的参数(平台标识,如 "macos-arm64"), + # 被循环变量遮蔽后,循环之后再用它会拿到 Path 而不是平台名。 + destination = bundle_root / relative_file # PACKAGE_FILES 含子目录路径(如 licenses/APACHE-2.0.txt), # copy2 不会自动创建父目录。 - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(PROJECT_ROOT / relative_file, target) + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(PROJECT_ROOT / relative_file, destination) prepare_runtime_layout(bundle_root) return bundle_root From b3827f024a15f6d2de8f8890107926bac341298e Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 12:49:41 +0800 Subject: [PATCH 09/31] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90=E9=A1=B6?= =?UTF-8?q?=E5=B1=82=20item=20=E6=A0=A1=E9=AA=8C=E3=80=81=E4=BA=92?= =?UTF-8?q?=E6=96=A5=E6=9D=A5=E6=BA=90=E4=B8=8E=20custom=20input=20?= =?UTF-8?q?=E7=BC=96=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查报告的剩余项,均已在对应提交上复现并红绿验证: - 顶层 Responses item 的变体校验以「有没有 ``content`` 键」分流。加一个 无关的 ``content`` 键即可改走消息分支,绕过全部变体校验;而 ``_responses_content_part`` 里的其余检查点(必填字段、``detail`` 取值、 ``fps`` 类型、``chunking_strategy`` 形状)在顶层路径没有等价实现。 改为按 ``type`` 分流,整项交给同一套实现,顺带补齐形状归一化。 - ``instructions`` × ``caching`` 互斥只查顶层 ``instructions``, ``extra_body={"instructions": ...}`` 配顶层 ``caching`` 可绕过。SDK 会把 ``extra_body`` 合并进请求体,服务端看到的与顶层写法相同,两条来源都要查。 - custom tool 的结构化 ``input`` 用 ``str()`` 编码,对 dict/list 产出 Python repr(单引号、``None``/``True`` 字面量),而同一份数据经 ``assistant.tool_calls`` 路径会被 JSON 编码,两条路径给出不同文本。 新增回归测试 18 项,红绿验证:回退源码后 11 项失败(另 7 项是既有覆盖或 等价输入的护栏)。 Co-Authored-By: Claude Opus 5 (1M context) --- src/providers/__base__/model_provider.py | 77 +++++++---- src/providers/volcengine.py | 14 +- tests/providers/test_sdk_capabilities.py | 164 +++++++++++++++++++++++ 3 files changed, 227 insertions(+), 28 deletions(-) diff --git a/src/providers/__base__/model_provider.py b/src/providers/__base__/model_provider.py index 56b8d3c..490a40c 100644 --- a/src/providers/__base__/model_provider.py +++ b/src/providers/__base__/model_provider.py @@ -384,7 +384,7 @@ def _chat_tool_call_item( source_type = normalized.get("type") # 非字符串的 ``type``(例如 ``["function"]``)直接做集合查找会抛 # ``TypeError: unhashable type``,把「类型不合法」变成崩溃。此处与 - # ``_reject_unsupported_responses_item`` 的守卫保持同一口径:先做 + # ``_normalize_responses_native_item`` 的守卫保持同一口径:先做 # isinstance 短路,再按取值映射。 tool_type = ( source_type @@ -525,27 +525,52 @@ def _responses_provider_variant(provider: str) -> str: # Ark 专属的 Responses 内容块类型;OpenAI 兼容端点不接受这些块。 _ARK_ONLY_RESPONSES_ITEM_TYPES = frozenset({"input_audio", "audio_url", "input_video", "video_url"}) +# 顶层 item 形态能书写的全部内容块类型。这些类型在 ``_responses_content_part`` +# 里已有完整校验(provider 变体、必填字段、取值范围),顶层路径必须复用同一套 +# 实现,否则会出现「写成 content 被拒、写成顶层 item 却放行」的不一致。 +_RESPONSES_CONTENT_BLOCK_TYPES = frozenset( + { + "text", + "input_text", + "output_text", + "audio_url", + "input_audio", + "video_url", + "input_video", + "image_url", + "input_image", + "file", + "input_file", + } +) -def _reject_unsupported_responses_item( + +def _normalize_responses_native_item( item: Mapping[str, Any], location: str, provider: str, -) -> None: - """拒绝经顶层 item 形态绕过 variant 守卫的供应商专属内容块。 - - ``normalize_responses_input`` 对带 ``content`` 的消息走 - ``_responses_message_content``,那里会按 provider 变体拒绝 Ark 专属块。 - 但原生 Responses item(``{"type": "input_audio", ...}``)没有 ``content``, - 会直通到请求体,因此需要在这里做等价检查,避免同一种块因书写位置不同 - 而一个被拒、一个静默发给不支持它的端点。 +) -> dict[str, Any]: + """归一化原生 Responses item,并复用内容块的完整校验。 + + 原生 item(``{"type": "input_audio", ...}``)不带 ``content``,会直通到 + 请求体。若只在这里做少量检查,同一种块会因书写位置不同而一个被拒、一个 + 静默发给不支持它的端点——加一个无关的 ``content`` 键就能改走另一条路径, + 从而绕过全部变体校验。 + + 因此类型属于内容块时,整项交给 ``_responses_content_part``:它同时完成 + 校验与形状归一化(例如把嵌套的 ``image_url`` 对象展平成 SDK 期望的扁平 + 形式)。其余键按原样保留。 """ - if _responses_provider_variant(provider) == "ark": - return item_type = item.get("type") - if item_type in _ARK_ONLY_RESPONSES_ITEM_TYPES: - raise ValueError(f"{location} 的 {item_type} 内容块当前不受 Responses SDK 支持。") - if "image_pixel_limit" in item: - raise ValueError(f"{location}.image_pixel_limit 不受 OpenAI Responses SDK 支持。") + if item_type not in _RESPONSES_CONTENT_BLOCK_TYPES: + return dict(item) + converted = _responses_content_part(item, location, provider=provider) + # 内容块转换只产出 SDK 输入联合里的字段;``id``/``status`` 等 item 级 + # 元数据不在其中,需要保留。 + for key in ("id", "status"): + if key in item and key not in converted: + converted[key] = item[key] + return converted def _responses_content_part( @@ -857,7 +882,11 @@ def _responses_function_call_item( # Completions 唯一允许的 ``function``,直接读它会丢掉 custom 语义。 source_type = tool_call.get("_responses_tool_call_type") or tool_call.get("type") if source_type == "custom_tool_call": - custom_input = "" if raw_arguments is None else str(raw_arguments) + # SDK 契约里 ``input`` 是任意文本。结构化值(dict/list)必须走 JSON + # 编码而不是 ``str()``:后者产出 Python repr(单引号、``None``/``True`` + # 字面量),同一份数据经 ``assistant.tool_calls`` 路径会被 JSON 编码, + # 两条路径对同一输入给出不同文本。 + custom_input = "" if raw_arguments is None else normalize_tool_arguments(raw_arguments) item: dict[str, Any] = { "type": "custom_tool_call", "call_id": call_id, @@ -1055,12 +1084,14 @@ def normalize_responses_input( location, provider=provider, ) - else: - # 原生 Responses item 形态(例如 ``{"type": "input_audio", ...}``) - # 不带 role/content,因此不会经过 _responses_message_content 的 - # 内容块转换。同一个 Ark 专属块写成 content 会被拒、写成顶层 item - # 却静默发出,等于绕过了 variant 守卫,因此这里补一次等价检查。 - _reject_unsupported_responses_item(normalized_message, location, provider) + # 顶层 item 形态(例如 ``{"type": "input_audio", ...}``)不带 role, + # 会直通到请求体。这里统一做一次归一化与校验——注意不能以「有没有 + # ``content`` 键」来分流:加一个无关的 ``content`` 键就能改走上面的 + # 分支,从而绕过顶层路径的全部变体校验。 + if normalized_message.get("type") in _RESPONSES_CONTENT_BLOCK_TYPES: + normalized_message = _normalize_responses_native_item( + normalized_message, location, provider + ) converted.append(normalized_message) return converted diff --git a/src/providers/volcengine.py b/src/providers/volcengine.py index f76dd48..776cdea 100644 --- a/src/providers/volcengine.py +++ b/src/providers/volcengine.py @@ -1257,14 +1257,18 @@ def _reject_instructions_with_enabled_caching(params: Mapping[str, Any]) -> None 缓存,``caching`` 为 ``enabled`` 时服务端直接报错。SDK 不做本地校验, 会原样发到服务端,因此在构造阶段显式拒绝,避免用户从远端 400 反推。 - ``caching`` 有两条来源:顶层参数,以及 ``extra_body``(Ark SDK 在 - ``_base_client`` 里把 ``extra_body`` 合并进请求体,服务端看到的仍是 - ``caching=enabled``)。两条都要检查,否则该守卫可被绕过。 + ``instructions`` 与 ``caching`` 各有两条来源:顶层参数,以及 + ``extra_body``(Ark SDK 在 ``_base_client`` 里把 ``extra_body`` 合并 + 进请求体,服务端看到的仍是同名参数)。只查顶层会漏掉 + ``extra_body={"instructions": ...}`` 配顶层 ``caching`` 这种组合。 """ - if params.get("instructions") is None: + extra_body = params.get("extra_body") + instructions = params.get("instructions") + if instructions is None and isinstance(extra_body, Mapping): + instructions = extra_body.get("instructions") + if instructions is None: return candidates = [params.get("caching")] - extra_body = params.get("extra_body") if isinstance(extra_body, Mapping): candidates.append(extra_body.get("caching")) for candidate in candidates: diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index 0f192e1..6754761 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -6845,3 +6845,167 @@ async def _collect_ainvoke(): == "这是真实回答。" ) assert asyncio.run(_collect_ainvoke()) == ["这是真实回答。"] + + +# ── 回归:顶层 item 形态不能绕过内容块的变体校验 ── + + +@pytest.mark.parametrize( + "message", + [ + {"type": "input_audio", "audio_url": "u"}, + # 加一个无关的 ``content`` 键就能改走消息分支,从而绕过顶层校验。 + {"type": "input_audio", "audio_url": "u", "content": "x"}, + {"role": "user", "content": "x", "type": "input_audio", "audio_url": "u"}, + {"type": "input_image", "image_url": {"url": "u", "image_pixel_limit": {"m": 1}}}, + ], +) +def test_openai_responses_rejects_ark_only_blocks_with_or_without_content_key(message): + """Ark 专属块写成顶层 item 时同样要拒。 + + 分流若以「有没有 ``content`` 键」为依据,攻击者加一个无关的 ``content`` + 键即可改走另一条路径,绕过全部变体校验。 + """ + with pytest.raises(ValueError, match="不受|缺少"): + normalize_responses_input(None, None, [message], provider="openai") + + +@pytest.mark.parametrize( + "message", + [ + {"type": "input_audio", "content": "x"}, + {"type": "input_file", "filename": "a.txt"}, + {"type": "input_video", "video_url": "u", "fps": "fast"}, + ], +) +def test_ark_responses_validates_native_item_required_fields(message): + """顶层 item 形态也要做必填字段与取值范围校验,不能只查类型。""" + with pytest.raises(ValueError): + normalize_responses_input(None, None, [message], provider="ark") + + +@pytest.mark.parametrize( + "message", + [ + {"type": "input_audio", "audio_url": "https://x/a.mp3"}, + {"type": "input_video", "video_url": "https://x/v.mp4", "fps": 2}, + { + "type": "input_image", + "image_url": "https://x/i.png", + "image_pixel_limit": {"max_pixels": 100}, + }, + ], +) +def test_ark_responses_still_accepts_valid_native_items(message): + """补全校验后,Ark 合法块不能被误拦。""" + converted = normalize_responses_input(None, None, [message], provider="ark") + + assert converted[0]["type"] == message["type"] + + +# ── 回归:instructions 与 caching 的互斥检查要覆盖 extra_body ── + + +@pytest.mark.parametrize( + "request_kwargs", + [ + # instructions 在 extra_body、caching 在顶层 + { + "prompt": "hi", + "system_prompt": None, + "extra_body": {"instructions": "sys"}, + "caching": {"type": "enabled"}, + }, + # 顶层 instructions、caching 在 extra_body + { + "prompt": "hi", + "system_prompt": "sys", + "extra_body": {"caching": {"type": "enabled"}}, + }, + # 两者都在 extra_body + { + "prompt": "hi", + "system_prompt": None, + "extra_body": {"instructions": "sys", "caching": {"type": "enabled"}}, + }, + ], +) +def test_ark_responses_mutual_exclusion_covers_extra_body(request_kwargs): + """``instructions`` 与 ``caching`` 各有顶层和 ``extra_body`` 两条来源。 + + SDK 会把 ``extra_body`` 合并进请求体,服务端看到的参数与顶层写法相同, + 因此只查顶层会漏掉这些组合。 + """ + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + with pytest.raises(ValueError, match="互斥"): + provider._build_responses_request(CompletionRequest(stream=False, **request_kwargs)) + + +def test_ark_responses_mutual_exclusion_allows_either_alone(): + """互斥检查不能误伤只出现一个的合法请求。""" + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + only_instructions = provider._build_responses_request( + CompletionRequest(prompt="hi", system_prompt="sys", stream=False) + ) + only_caching = provider._build_responses_request( + CompletionRequest( + prompt="hi", system_prompt=None, caching={"type": "enabled"}, stream=False + ) + ) + + assert only_instructions["instructions"] == "sys" + assert only_caching["caching"] == {"type": "enabled"} + + +# ── 回归:custom tool 的结构化 input 编码要与 assistant 路径一致 ── + + +@pytest.mark.parametrize( + "value,expected", + [({"a": 1}, '{"a":1}'), ([1, 2], "[1,2]"), ("plain text", "plain text")], +) +def test_responses_custom_tool_input_uses_json_encoding(value, expected): + """``str()`` 对 dict/list 产出 Python repr(单引号),而同一份数据经 + ``assistant.tool_calls`` 路径会被 JSON 编码,两条路径给出不同文本。""" + from src.providers.__base__.model_provider import _responses_function_call_item + + item = _responses_function_call_item( + {"id": "c1", "type": "custom_tool_call", "name": "n", "input": value}, "loc" + ) + + assert item["input"] == expected + + +def test_responses_custom_tool_input_matches_assistant_path_encoding(): + """两条路径对同一输入必须给出同一文本。""" + from src.providers.__base__.model_provider import _responses_function_call_item + + payload = {"nested": {"a": 1}, "list": [1, 2]} + + via_item = _responses_function_call_item( + {"id": "c1", "type": "custom_tool_call", "name": "n", "input": payload}, "loc" + ) + via_assistant = normalize_responses_input( + None, + None, + [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + {"id": "c1", "type": "custom_tool_call", "name": "n", "input": payload} + ], + } + ], + provider="ark", + ) + + assert via_item["input"] == via_assistant[0]["input"] From fca8a4d8294066f31bf5e85241fb8100c0cb142e Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 13:03:43 +0800 Subject: [PATCH 10/31] =?UTF-8?q?fix:=20=E4=BB=8E=20dir()=20=E9=9A=90?= =?UTF-8?q?=E8=97=8F=E8=B5=84=E6=BA=90=E4=BB=A3=E7=90=86=E7=9A=84=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0=E7=BB=86=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``__slots__`` 里的名字默认都会出现在 ``dir()`` 中,IDE 会把 ``_value`` 提示成公共 API。它并不是出口——文档化的出口是 ``.native``(按设计返回完整 原生树、不经过凭证扫描与能力门禁),``_value`` 指向同一个对象,因此不构成 独立的绕过路径。这里只是不让自动补全把实现细节呈现给使用者。 另补一条 dict 形态响应的提取测试:``field()`` 同时支持属性访问与 Mapping, 非标准网关透传的 JSON 也要走同一条类型过滤。 Co-Authored-By: Claude Opus 5 (1M context) --- src/providers/resources.py | 13 +++++++ tests/providers/test_sdk_capabilities.py | 47 ++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/providers/resources.py b/src/providers/resources.py index 015c0cf..551a727 100644 --- a/src/providers/resources.py +++ b/src/providers/resources.py @@ -39,10 +39,23 @@ class _NativeResourceProxy: `.native` 仍然返回官方客户端本身;只有通过 Facade 动态访问的资源节点 使用此代理。这样既能兼容 SDK 新增资源,又不会让 `extra_headers`、 `extra_query` 或 `extra_body` 绕过统一凭证边界。 + + 绕过说明:``.native`` 是文档化的出口,按设计返回完整原生树,不经过 + 凭证扫描与能力门禁。本代理内部的 ``_value`` 指向同一个对象,因此它 + 不是独立的绕过路径——想绕过门禁的用户用 ``.native`` 即可,无需依赖 + 实现细节。``_value`` 只是内部持有者,不属于公共 API。 """ __slots__ = ("_children", "_path", "_provider", "_value") + def __dir__(self) -> list[str]: + """从自动补全中隐藏实现细节,避免被当成公共 API 使用。 + + ``__slots__`` 里的名字默认都会出现在 ``dir()`` 中,IDE 会把 + ``_value`` 提示给用户;而它并不是出口(出口是 ``.native``)。 + """ + return [name for name in super().__dir__() if name not in self.__slots__] + def __init__(self, value: Any, provider: Any, path: str): self._value = value self._provider = provider diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index 6754761..109f724 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -7009,3 +7009,50 @@ def test_responses_custom_tool_input_matches_assistant_path_encoding(): ) assert via_item["input"] == via_assistant[0]["input"] + + +def test_ark_responses_output_text_supports_mapping_responses(): + """``field()`` 同时支持属性访问与 Mapping。dict 形态响应(非标准网关或 + 中间层透传的 JSON)也要走同一条提取路径与类型过滤。 + """ + from src.providers.volcengine import _ark_responses_output_text + + response = { + "output": [ + { + "type": "message", + "content": [ + {"type": "output_text", "text": "正文"}, + {"type": "refusal", "refusal": "拒绝内容"}, + ], + }, + {"type": "reasoning", "content": [{"type": "reasoning_text", "text": "思考"}]}, + ] + } + + # 只取 output_text 块:refusal 与 reasoning 不能混进正文。 + assert _ark_responses_output_text(response) == "正文" + + +def test_ark_responses_native_item_proxy_hides_implementation_slots(): + """``__slots__`` 里的名字默认出现在 ``dir()`` 中,IDE 会把 ``_value`` + 提示成公共 API;而真正的出口是文档化的 ``.native``。 + """ + provider = object.__new__(VolcengineProvider) + provider._provider = "volcengine" + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._server_verified_protocols = frozenset() + provider._options = {} + object.__setattr__( + provider, + "_client", + SimpleNamespace(responses=SimpleNamespace(create=lambda **_kwargs: "remote")), + ) + + proxy = provider.resources.responses + + for internal in ("_value", "_path", "_provider", "_children"): + assert internal not in dir(proxy), internal + # 隐藏的是自动补全,不是访问能力。 + assert proxy._value is provider._client.responses From ee4c6f0971d17c7e0d5468601f63fb11adf0fe68 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 13:08:24 +0800 Subject: [PATCH 11/31] =?UTF-8?q?docs:=20=E5=9C=A8=20CHANGELOG=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E6=9C=AC=E8=BD=AE=E4=BF=AE=E5=A4=8D=E6=9D=A1=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1.4.0 尚未发布(main 上无该段),本轮修复属于同一版本。补齐此前只有 高层描述、没有对应条目的守卫:动态资源树门禁、顶层 item 变体校验、 流式工具调用 id 语义、Ark 失败判定放宽,以及脱敏边界的三处修复。 测试计数同步为 958,并说明新增断言均经红绿验证。 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6c825a..39276a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ - Ark Responses 在构造阶段拒绝 `instructions` 与 `caching={"type": "enabled"}` 同时出现:官方规定配置 `instructions` 后本轮请求无法写入或使用缓存,`caching` 为 `enabled` 时服务端直接报错,而 SDK 不做本地校验。错误信息会指出 `instructions` 来自 `system_prompt` 的兼容默认值,并给出改走 `messages` 的修复方式。 - 新增原生资源 Facade,显式暴露文件、批处理、缓存、向量库、token 计数、调优等 SDK 能力,不再把资源生命周期混入普通聊天请求。 - 深度适配 Google GenAI、Anthropic、Volcengine Ark、Jina 与 SiliconFlow Rerank;OpenAI 兼容渠道复用 `openai` SDK,Jina 与 SiliconFlow Rerank 使用 HTTP JSON。 +- 收紧动态资源树的能力门禁:此前按路径分段判断时只认复数 `responses`,而 `input_items`(实际请求 `/responses/{id}/input_items`)不含该分段,在未登记 `server_verified_protocols` 的渠道上也能把请求发到远端;同时动态路径只做能力检查、不执行 Facade 的参数校验,可带着互斥参数直接发出。现在两条路径共用同一套校验,`extra_body` 里的 `instructions`/`caching` 同样参与互斥检查。 +- 顶层 Responses item 形态(`{"type": "input_audio", ...}`)补齐变体校验:此前只在缺少 `content` 键时检查,加一个无关的 `content` 键即可改走消息分支绕过全部校验;现在按 `type` 分流并复用同一套内容块实现。 +- 修复 Responses 流式工具调用的 `id` 语义:`id` 此前在 delta 事件取输出项 ID、在 done 事件取调用 ID、合并时又互相覆盖,同一轮工具调用在 delta 与 completed 事件里得到不同的值,调用方按 `tool_call["id"]` 回填 `role=tool` 时会配不上。现在与非流式路径统一取调用标识符。 +- 放宽 Ark Responses 的失败判定:内置工具调用项(`web_search_call`、`mcp_call`、`mcp_list_tools`、空摘要的 `reasoning`)都是 SDK 输出项联合的正式成员,属正常中间态,此前被误判为「响应结构与预期不符」而无法处理。 - Embedding 区分文档与查询任务类型:Google 使用 `RETRIEVAL_DOCUMENT` 与 `RETRIEVAL_QUERY`。 ### 安全 @@ -22,6 +26,9 @@ - 凭证键识别补齐火山引擎的 `ak`/`sk` 与 Azure 存储的 `account_key`;此前这些键可绕过请求覆盖校验,而 `api_key` 会被拒绝。 - 日志与错误信息统一脱敏常见凭证表示(Bearer、API key、URL userinfo 与 query);补齐 `ak=`/`sk=`/`account_key=` 形式,以及服务端错误里「首尾可见、中间掩码」的凭证形态(如 `sk-abc***...***xyz`,此前会原样落进日志)。 - 修复脱敏日志 formatter 的缓存泄漏:`logging.Formatter` 会把格式化后的 traceback 缓存在 `record.exc_text` 上供后续 handler 复用,先格式化后脱敏会让同一 logger 上的非脱敏 handler 输出原始凭证;现在脱敏结果会写回缓存。 +- 文本脱敏的键名识别与配置边界对齐:此前词表更窄,且键名前要求非字母数字字符,`dbPassword`、`myApiKey` 这类驼峰键在配置边界被拒绝、在日志里却明文输出。同时给值加上形态约束,避免 `auth: none`、`cookie: enabled` 这类普通文本被误判为凭证而让合法 options 在边界被拒。 +- 词边界改用「非 ASCII 字母数字」而非 `\b`:中文属 `\w`,`\b` 在 `密钥sk-...` 两侧都不成立,国产网关(SiliconFlow、Ark、DashScope)的中文错误消息此前会让密钥整串落进终端与日志。 +- 配置校验失败不再把凭证交给解释器默认 handler:`Settings()` 在 import 期就被调用(`log_manager.get_module_logger`),早于任何入口的 `try`,pydantic 默认渲染会把 `input_value` 明文打印。现在在 `get_settings` 边界重抛已脱敏的消息,并给 `run_cli` 补上覆盖整个启动阶段的异常边界。 - 兼容渠道不再被当作官方 OpenAI 端点放行 SDK 专属资源;仅精确匹配 `https://api.openai.com/v1` 时按 SDK 契约放行。 - 收紧失败语义:凭证、SDK 或远端调用失败时显式报错,不再以空 embedding、零分 rerank 或占位回答掩盖失败。 @@ -37,7 +44,7 @@ ### 测试与文档 -- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 823。 +- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 958。新增断言均经红绿验证(回退源码后对应测试变红),并替换了两处无区分度的旧断言:URL query 脱敏断言此前用的长值由另一条规则满足,Ark 响应夹具此前伪造了 SDK 不存在的 `output_text` 字段。 - 为活动快照的 embedding 兼容性检测补充回归测试:快照记录的 embedding provider 或模型名与当前运行配置不一致时必须显式失败,避免用错向量空间后静默产出错误检索结果。 - 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置;新增 `.github/workflows/quality.yml`,在 PR 与 `main` 推送时执行格式化检查、lint、类型检查、安全扫描与完整测试,此前这些工具只在本地手动运行。 - 全仓库应用 `ruff format`(行宽 100、双引号、4 空格),此前未配置 formatter,`main.py`、`src/`、`scripts/`、`tests/` 中存在混用单引号、行尾空白和手工对齐等不一致;格式化只改表示不改语义,已用 AST 比对确认语法树等价,`AGENTS.md` 的质量检查与风格段落同步更新。 From 3b4c21ecc7665715d9330b1cb54fb6f32122bde9 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 15:33:39 +0800 Subject: [PATCH 12/31] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=E8=84=B1=E6=95=8F=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E3=80=81=E9=97=A8=E7=A6=81=E7=BC=BA=E5=8F=A3=E4=B8=8E?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E4=BB=A3=E7=90=86=E5=87=BA=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多轮审查(5 个独立 agent)发现以下问题,均实测复现并红绿验证: 脱敏边界(security.py) - 上一轮给全部键名加的值形态约束(「引号包裹 / 含数字符号 / ≥12 字符」) 使短且纯字母的凭证整条漏检:``password: huntertwo``、``token: mytoken`` 从脱敏变明文,同时削弱文本脱敏与配置边界校验。那是比误判更严重的净漏检。 改为用非凭证字面量 denylist 排除误判源(``auth: none``、``cookie: enabled``), 显式凭证键名保留任意值。 - 键名前的界断言不能简单删掉:``oauth``、``topsecret``、``sessiontoken`` 会从 词中间命中 ``auth``/``secret``/``token``,而配置边界判它们非敏感(切词后不 命中),误拒合法配置。改用「非字母数字或 camelCase 边界」,并把 ``(?i)`` 局部化到键名组——写在最前面会让 ``[A-Z]`` 匹配任意大小写,``oauth`` 的 ``o``→``a`` 被误判为驼峰边界。 - 掩码规则的尾部前瞻逐字符重复扫描剩余串,尾部无冒号时退化成 O(n²): ``"sk-abc***" + "deadbeef"*500`` 从 0.01ms 涨到 11ms,4000 字符时 1.4 秒。 而 redact_sensitive_text 挂在每条日志的 formatter 上。改为无回溯的贪婪匹配 加替换函数裁剪——同时修掉尾部可见片段残留(``sk-abc***xyz: boom`` 的 ``xyz`` 变明文)与掩码吞掉紧邻键名(``sk-abc***token=`` 的值变明文)。 Ark 门禁(volcengine.py、openai_compatible.py) - fail-closed 白名单漏了 SDK 联合成员 ``image_process``、``agent_tool_call``, 这类响应会无法处理;同时 ``reasoning`` 不该豁免——空摘要且无正文时确实是 什么都没有,豁免会让它静默返回空成功,掩盖失败。 - ``caching={"type": "ENABLED"}`` 绕过互斥检查:SDK 的 Literal 注解不做运行时 校验,精确比较漏掉大小写与空白变体。 - 动态路径按 ``{"create", "generate"}`` 动词白名单决定是否做参数校验,漏掉 ``async_create`` 这类 SDK 演进后新增的写法。改为按参数里是否出现受校验字段。 - 兼容渠道的动态路径能力映射只覆盖 responses/files,``resources.batches.create`` 等拿到 None 直接放行,而显式名 ``create_batch`` 会被拦;``uploads`` 还与 ``files`` 坍缩成同一能力。 资源代理(resources.py) - ``__slots__`` 封闭了自有 ``__dict__``,但 ``__getattr__`` 把私有名转发给底层 SDK 节点:``proxy.__dict__["_client"]`` 能拿到未包装的原始客户端,绕开全部 凭证扫描与能力门禁。改为拒绝下划线名转发,``dir()`` 只列公开资源名。 归一化(model_provider.py) - ``image_pixel_limit`` 是 Ark 专属字段,与 ``type``/``content``/``role`` 都无关。 此前只在「有 content 键」或「类型是内容块」时检查,加一个无关的 ``content`` 或 ``type`` 键即可绕过。抽出共用检查覆盖全部书写位置。 - ``type`` 是任意 JSON 值,集合查找对不可哈希的值抛 ``TypeError: unhashable type``——正是本批要修的那类崩溃,却在新的分流点被 重新引入。两处都加 isinstance 守卫。 新增回归测试 47 项,红绿验证:回退源码后 35 项失败。 Co-Authored-By: Claude Opus 5 (1M context) --- src/providers/__base__/model_provider.py | 47 +++- src/providers/openai_compatible.py | 44 +++- src/providers/resources.py | 21 +- src/providers/volcengine.py | 36 ++- src/utils/security.py | 136 +++++++++-- tests/providers/test_sdk_capabilities.py | 249 +++++++++++++++++++- tests/providers/test_security_boundaries.py | 92 ++++++++ 7 files changed, 576 insertions(+), 49 deletions(-) diff --git a/src/providers/__base__/model_provider.py b/src/providers/__base__/model_provider.py index 490a40c..7a4846c 100644 --- a/src/providers/__base__/model_provider.py +++ b/src/providers/__base__/model_provider.py @@ -545,6 +545,22 @@ def _responses_provider_variant(provider: str) -> str: ) +def _reject_unsupported_responses_top_level_fields( + item: Mapping[str, Any], + location: str, + provider: str, +) -> None: + """拒绝 OpenAI 兼容渠道发出 Ark 专属的顶层字段。 + + 这类字段与 item 类型无关:无论写成内容块、原生 item 还是普通消息的顶层 + 字段,OpenAI Responses 都不接受。只检查内容块路径会留下旁路。 + """ + if _responses_provider_variant(provider) == "ark": + return + if "image_pixel_limit" in item: + raise ValueError(f"{location}.image_pixel_limit 不受 OpenAI Responses SDK 支持。") + + def _normalize_responses_native_item( item: Mapping[str, Any], location: str, @@ -561,9 +577,20 @@ def _normalize_responses_native_item( 校验与形状归一化(例如把嵌套的 ``image_url`` 对象展平成 SDK 期望的扁平 形式)。其余键按原样保留。 """ + # ``image_pixel_limit`` 是 Ark 专属字段,与 item 类型无关:无论写成内容块 + # 还是顶层字段,OpenAI 兼容渠道都不能发出。这个检查必须在类型门禁之前, + # 否则 ``{"type": "reasoning", "image_pixel_limit": ...}`` 这类非内容块 + # item 会整体跳过校验——只是把绕过点从 ``content`` 键换成了 ``type`` 键。 + _reject_unsupported_responses_top_level_fields(item, location, provider) + item_type = item.get("type") - if item_type not in _RESPONSES_CONTENT_BLOCK_TYPES: - return dict(item) + # ``type`` 可能是任意 JSON 值。直接做集合查找对不可哈希的值(``list``/ + # ``dict``)会抛 ``TypeError: unhashable type``,把可诊断的类型错误变成 + # 崩溃;非字符串的 ``type`` 一律交给 ``_responses_content_part`` 报错。 + if not isinstance(item_type, str) or item_type not in _RESPONSES_CONTENT_BLOCK_TYPES: + if isinstance(item_type, str) or item_type is None: + return dict(item) + return _responses_content_part(item, location, provider=provider) converted = _responses_content_part(item, location, provider=provider) # 内容块转换只产出 SDK 输入联合里的字段;``id``/``status`` 等 item 级 # 元数据不在其中,需要保留。 @@ -595,6 +622,11 @@ def _responses_content_part( source = dict(part) part_type = source.get("type") + # ``type`` 是任意 JSON 值。下面所有分支都用集合成员判断,对不可哈希的值 + # (``list``/``dict``)会抛 ``TypeError: unhashable type``,把可诊断的 + # 类型错误变成崩溃。非字符串统一在此显式失败。 + if not isinstance(part_type, str) or not part_type.strip(): + raise ValueError(f"{location}.type 必须是非空字符串。") variant = _responses_provider_variant(provider) if part_type in {"text", "input_text", "output_text"}: text = source.get("text") @@ -821,8 +853,6 @@ def _responses_content_part( raise ValueError(f"{location}.prompt_cache_breakpoint 不受 Ark Responses SDK 支持。") return converted - if not isinstance(part_type, str) or not part_type.strip(): - raise ValueError(f"{location}.type 必须是非空字符串。") raise ValueError(f"{location} 的内容类型 {part_type} 不受 Responses SDK 支持。") @@ -1084,11 +1114,18 @@ def normalize_responses_input( location, provider=provider, ) + # 顶层字段也要查:``image_pixel_limit`` 与 ``type``/``content`` 都无关, + # 写在消息顶层时不会经过内容块转换,OpenAI 兼容渠道不能发出。 + _reject_unsupported_responses_top_level_fields(normalized_message, location, provider) # 顶层 item 形态(例如 ``{"type": "input_audio", ...}``)不带 role, # 会直通到请求体。这里统一做一次归一化与校验——注意不能以「有没有 # ``content`` 键」来分流:加一个无关的 ``content`` 键就能改走上面的 # 分支,从而绕过顶层路径的全部变体校验。 - if normalized_message.get("type") in _RESPONSES_CONTENT_BLOCK_TYPES: + # 归一化并校验顶层 item 形态。这里不能只判「``type`` 是否在内容块 + # 集合里」:集合查找对不可哈希的值会抛 ``TypeError``;也不能只在没有 + # ``role`` 时调用——``{"role": "user", "type": "input_audio", ...}`` + # 会跳过类型校验直通请求体。 + if "role" not in normalized_message or "type" in normalized_message: normalized_message = _normalize_responses_native_item( normalized_message, location, provider ) diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index 0d6325a..576f9ca 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -3,7 +3,7 @@ import math import time from collections.abc import AsyncGenerator, AsyncIterator, Generator, Iterator, Mapping -from typing import Any, cast +from typing import Any, ClassVar, cast from urllib.parse import urlsplit import openai @@ -597,17 +597,45 @@ def _resource_capability_for_method(method_name: str) -> str | None: # 与显式 Facade 名(``create_response``)不同形。只看显式名会让动态路径 # 拿到 capability=None 直接放行,正是 server_verified_protocols 门禁要堵的 # 旁路。这里按路径分段识别资源树。 - _RESPONSES_RESOURCE_SEGMENTS = frozenset({"responses", "input_items", "input_tokens"}) - _FILES_RESOURCE_SEGMENTS = frozenset({"files", "uploads"}) + # 资源树分段 → 能力名。必须与 ``_resource_capability_for_method`` 的显式 + # 映射覆盖同一批能力:只覆盖 responses/files 会让 + # ``resources.batches.create`` 这类写法拿到 capability=None 直接放行, + # 而显式名 ``create_batch`` 会被拦——同一能力因书写形式不同而区别对待, + # 正是本门禁要消灭的旁路。 + # + # ``uploads`` 与 ``files`` 是两项独立能力(SDK 里也分属不同资源), + # 不能坍缩成同一个名字。 + _RESOURCE_SEGMENTS_TO_CAPABILITY: ClassVar[Mapping[str, str]] = { + "responses": "responses", + "input_items": "responses", + "input_tokens": "responses", + "files": "files", + "uploads": "uploads", + "batches": "batches", + "vector_stores": "vector_stores", + "models": "models", + "moderation": "moderation", + "images": "images", + "audio": "audio", + "videos": "videos", + "conversations": "conversations", + "containers": "containers", + "fine_tuning": "fine_tuning", + "evals": "evals", + "skills": "skills", + "realtime": "realtime", + "webhooks": "webhooks", + "admin": "admin", + "content_provenance_checks": "content_provenance_checks", + } @classmethod def _resource_capability_for_path(cls, method_name: str) -> str | None: """从动态资源树路径推断能力名称。""" - segments = method_name.split(".") - if any(segment in cls._RESPONSES_RESOURCE_SEGMENTS for segment in segments): - return "responses" - if any(segment in cls._FILES_RESOURCE_SEGMENTS for segment in segments): - return "files" + for segment in method_name.split("."): + capability = cls._RESOURCE_SEGMENTS_TO_CAPABILITY.get(segment) + if capability is not None: + return capability return None def _require_provider_resource( diff --git a/src/providers/resources.py b/src/providers/resources.py index 551a727..3f67f10 100644 --- a/src/providers/resources.py +++ b/src/providers/resources.py @@ -51,10 +51,16 @@ class _NativeResourceProxy: def __dir__(self) -> list[str]: """从自动补全中隐藏实现细节,避免被当成公共 API 使用。 - ``__slots__`` 里的名字默认都会出现在 ``dir()`` 中,IDE 会把 - ``_value`` 提示给用户;而它并不是出口(出口是 ``.native``)。 + 两处来源都要过滤:``__slots__`` 里的自有槽位,以及 ``__getattr__`` + 转发来的底层 SDK 节点属性(其中含 ``_client`` 这类可直达原始客户端的 + 通路)。出口是文档化的 ``.native``。 """ - return [name for name in super().__dir__() if name not in self.__slots__] + # ``super().__dir__()`` 对 ``__slots__`` 类只给出 dunder 与槽位名, + # 真实资源名要经 ``__getattr__`` 从底层节点取(并缓存进 ``_children``)。 + names = {name for name in super().__dir__() if not name.startswith("_")} + names.update(name for name in self._children if not name.startswith("_")) + names.update(name for name in dir(self._value) if not name.startswith("_")) + return sorted(names) def __init__(self, value: Any, provider: Any, path: str): self._value = value @@ -62,7 +68,16 @@ def __init__(self, value: Any, provider: Any, path: str): self._path = path self._children: dict[str, Any] = {} + # 底层 SDK 节点的私有名不能经代理转发。``_NativeResourceProxy`` 没有 + # 自己的 ``__dict__``(``__slots__`` 已封闭),因此 ``__getattr__`` 会把 + # 这些名字转发给 SDK 节点——``proxy.__dict__["_client"]`` 能拿到未包装的 + # 原始客户端,绕开全部凭证扫描与能力门禁。``.native`` 是文档化的出口, + # 这里要堵的是「实现细节意外成为第二条出口」。 + _BLOCKED_ATTRIBUTES = frozenset({"__dict__", "__class__", "__weakref__"}) + def __getattr__(self, name: str) -> Any: + if name in self._BLOCKED_ATTRIBUTES or name.startswith("_"): + raise AttributeError(f"{type(self).__name__} 不暴露 {name!r}。") if name in self._children: return self._children[name] value = getattr(self._value, name) diff --git a/src/providers/volcengine.py b/src/providers/volcengine.py index 776cdea..123fed4 100644 --- a/src/providers/volcengine.py +++ b/src/providers/volcengine.py @@ -58,7 +58,16 @@ def _load_ark_clients() -> tuple[type[Any], type[Any]]: # Responses 的 output 里,除 message 与 function_call 外还有内置工具的调用项。 # 这些项既无正文也不是 function_call,但都是 SDK ``ResponseOutputItem`` 联合的 -# 正式成员,属正常中间态而非「结构不符」。 +# 正式成员,且都是「需要后续轮次」的中间态——调用方要读 output 里的工具调用 +# 才能继续,因此返回空正文是正确的。 +# +# 不含 ``reasoning``:它不是工具调用,本轮有摘要时 ``reasoning`` 字段非空, +# 前面 ``and not reasoning`` 已经放行;只有空摘要且无正文、无工具调用时才走到 +# 失败分支,那确实是什么都没有,报错才对。把 reasoning 列入豁免会让这种响应 +# 静默返回空成功,掩盖失败(项目规则禁止)。 +# +# 白名单需与 SDK 联合成员保持同步,`test_ark_builtin_item_whitelist_covers_sdk_union` +# 会对差集断言。 _ARK_BUILTIN_TOOL_ITEM_TYPES = frozenset( { "web_search_call", @@ -67,9 +76,8 @@ def _load_ark_clients() -> tuple[type[Any], type[Any]]: "mcp_approval_request", "knowledge_search_call", "doubao_app_call", - # reasoning 项在模型本轮无推理摘要时 ``summary`` 为空列表,但项本身 - # 合法(SDK 已把它作为独立 item 类型返回)。 - "reasoning", + "image_process", + "agent_tool_call", } ) @@ -637,6 +645,12 @@ def _normalize_verified_protocols(cls, configured: Any) -> frozenset[str]: # (``volcengine.input_items.list`` 打的是 ``/responses/{id}/input_items``), # 只看分段会把它们漏掉;但 ``files.create`` 这类同名无关调用必须放行, # 因此用精确集合而不是子串匹配。 + # 这些字段出现即说明调用意图是「创建/发起 Responses 请求」,无论方法名 + # 是什么。用字段而非动词判断,避免 SDK 新增入口时漏检。 + _ARK_RESPONSES_VALIDATED_KWARGS = frozenset( + {"input", "instructions", "caching", "model", "tools", "extra_body"} + ) + _ARK_RESPONSES_SUBRESOURCES = frozenset( { "input_items", @@ -666,7 +680,11 @@ def _require_provider_resource( or any(segment in self._ARK_RESPONSES_SUBRESOURCES for segment in segments) ): self._require_responses_resource(method_name) - if kwargs and segments[-1] in {"create", "generate"}: + # 不按动词白名单判断(``{"create", "generate"}`` 会漏掉 + # ``async_create`` 这类 SDK 演进后新增的写法)。凡是参数里出现 + # 受校验字段就执行同一套校验——这正是「动态路径与 Facade 受同一 + # 约束」的判据。 + if kwargs and self._ARK_RESPONSES_VALIDATED_KWARGS.intersection(kwargs): self._validate_native_response_kwargs(kwargs) def _require_responses_resource(self, operation: str) -> None: @@ -1272,7 +1290,13 @@ def _reject_instructions_with_enabled_caching(params: Mapping[str, Any]) -> None if isinstance(extra_body, Mapping): candidates.append(extra_body.get("caching")) for candidate in candidates: - if isinstance(candidate, Mapping) and candidate.get("type") == "enabled": + if not isinstance(candidate, Mapping): + continue + # SDK 的 ``ResponseCaching.type`` 是 ``Literal["disabled","enabled"]``, + # 但那是类型注解、不做运行时校验:``"ENABLED"`` / ``" enabled"`` 会 + # 原样发到服务端。归一化后再比较,否则大小写与空白变体可绕过互斥检查。 + candidate_type = candidate.get("type") + if isinstance(candidate_type, str) and candidate_type.strip().lower() == "enabled": raise ValueError( 'Ark Responses 的 instructions 与 caching={"type": "enabled"} 互斥:' "官方规定配置 instructions 后本轮请求无法写入或使用缓存,caching 为 " diff --git a/src/utils/security.py b/src/utils/security.py index 90356f2..97c7c73 100644 --- a/src/utils/security.py +++ b/src/utils/security.py @@ -148,27 +148,81 @@ ) _TEXT_CREDENTIAL_KEY_PATTERN = "|".join(_TEXT_CREDENTIAL_KEYS) -# 值的形态约束:凭证值要么被引号包裹,要么含数字或符号,要么足够长。 -# 缺了它,``auth: none``、``cookie: enabled``、``secret: false`` 这类普通文本 -# 会被判为凭证——而 find_sensitive_option_paths 用 -# ``redact_sensitive_text(value) != value`` 判断值里有没有凭证,于是合法配置 -# (例如 vector_stores.search(query="...") 的检索词、extra_body 里的提示词或 -# JSON schema)会在边界被误拒。 -_CREDENTIAL_VALUE_SHAPE = ( - r"""(?:"[^"]*"|'[^']*'""" - r"""|(?=[A-Za-z0-9._~+/=-]*[0-9._~+/=-])[A-Za-z0-9._~+/=-]{2,}""" - r"""|[A-Za-z0-9._~+/=-]{12,})""" +# 键名前的界断言。这里必须比 ``\b`` 宽、比「任意位置」窄: +# +# - 不要 ``(? str: + """拼出 ``键名 = 值`` 的文本脱敏模式。 + + ``keys`` 里每个分支都是完整键名(可含 ``[_-]?``),配合 + ``_CREDENTIAL_KEY_HEAD`` 的「非字母数字或 camelCase 边界」断言,既让 + ``dbPassword``/``myApiKey`` 命中,又不把 ``topsecret``/``sessiontoken`` + 从词中间切开——与 ``is_sensitive_option_key`` 的切词口径一致。 + + ``value_prefix`` 插在取值之前,用于排除非凭证字面量。 + """ + return ( + _CREDENTIAL_KEY_HEAD + r"((?i:" + keys + r"))[\"']?" + r"""(\s*[:=]\s*|\s+(?=["']|""" + r"""(?=[A-Za-z0-9._~+/=-]{12,}(?:[\s,;}']|$))[A-Za-z0-9._~+/=-]*[0-9._~+/=-]))""" + + value_prefix + + _ANY_VALUE_SHAPE + ) + + +# 显式凭证键名(``password``、``api_key``、``client_secret``…)的值就是凭证, +# 任何取值都脱敏;裸关键词(``auth``/``cookie``/``secret``/``token``/``bearer``) +# 额外排除非凭证字面量。 +_KEY_VALUE_TEXT_RE = re.compile(_key_value_pattern(_QUALIFIED_CREDENTIAL_KEY_PATTERN)) +_BARE_KEYWORD_TEXT_RE = re.compile( + _key_value_pattern( + _BARE_CREDENTIAL_KEYWORD_PATTERN, + r"(?!(?:" + _NON_CREDENTIAL_VALUE + r")(?![A-Za-z0-9._~+/=-]))", + ) ) _URL_USERINFO_RE = re.compile(r"(?i)(https?://)([^\s/@:]+):([^\s/@]+)@") _URL_QUERY_SECRET_RE = re.compile( @@ -192,12 +246,43 @@ # 掩码段可含 ``*``、``.``、``…`` 等占位字符,尾部可能还有可见片段, # 因此尾部字符类要覆盖字母数字,否则 ``sk-abc***...***xyz`` 只吃掉前半段。 # - # 但尾部每消费一个字符都要确认它不是紧邻键名的开头:``sk-abc***token=`` - # 里的 ``token`` 若被吞进掩码匹配,后面的 ``token=`` 就失去锚点, - # 值会从脱敏变成明文(净漏检)。断言在键名之前停下,让键值规则处理它。 - r"(?i)(?`` 里的 ``token`` + # 若被吞进掩码匹配,后面的 ``token=`` 就失去锚点,值会从脱敏变成 + # 明文(净漏检)。 + # + # 尾部用单字符类贪婪匹配(无前瞻、无回溯),裁剪交给替换函数 + # ``_redact_masked_credential``:正则里加断言会让引擎在游程的每个起点 + # 重复扫描剩余串,尾部无冒号时退化成 O(n²)——``"sk-abc***" + "deadbeef"*500`` + # 从 0.01ms 涨到 11ms,4000 字符时 1.4 秒。而 redact_sensitive_text 挂在 + # 每条日志的 formatter 上,一个回显掩码密钥前缀加长 token 的错误体就能 + # 拖住进程。 + r"(?i)(? str: + """替换掩码凭证,但把混进尾部的敏感键名留给键值规则处理。 + + ``sk-abc***token=`` 里的 ``token`` 若被掩码整体吃掉,后面的 + ``=`` 就失去锚点,值会从脱敏变成明文(净漏检)。这里从尾部 + 游程中找出最长的敏感键名后缀并保留,让 ``_KEY_VALUE_TEXT_RE`` 接续 + 处理。 + + 只保留**完整键名**(能通过 ``is_sensitive_option_key``)的后缀:掩码 + 尾部本身可能就是可见片段(``sk-abc***xyz``),不能整段留下。 + """ + text = match.group(0) + # 键名是尾部那段连续的键名字符(``*``/``.``/``…`` 等掩码占位符不在其中), + # 整段判断是否为敏感键:``token``/``myApiKey`` 命中并保留, + # ``defghijkl``/``xyz`` 这类可见片段不命中,随掩码一起吃掉。 + # 不用「从某处截断取后缀」:``sk`` 本身就是火山凭证键名,从 ``s`` 起算会 + # 把 ``sk-abc***`` 切碎。 + trailing = re.search(r"[A-Za-z0-9_-]+$", text) + if trailing is not None and is_sensitive_option_key(trailing.group(0)): + return "[REDACTED]" + trailing.group(0) + return "[REDACTED]" + + _GOOGLE_API_KEY_RE = re.compile(r"(? str: value = str(value) # 掩码形态必须最先处理:``_BEARER_TEXT_RE`` 会先吃掉 ``Bearer sk-abc`` # 的可见前缀,使后续的 ``sk-`` 锚点失效,尾部掩码片段就会残留。 - redacted = _MASKED_CREDENTIAL_RE.sub("[REDACTED]", value) + redacted = _MASKED_CREDENTIAL_RE.sub(_redact_masked_credential, value) redacted = _BEARER_TEXT_RE.sub("Bearer [REDACTED]", redacted) redacted = _KEY_VALUE_TEXT_RE.sub(r"\1=[REDACTED]", redacted) + redacted = _BARE_KEYWORD_TEXT_RE.sub(r"\1=[REDACTED]", redacted) redacted = _SHORT_CREDENTIAL_KEY_RE.sub(r"\1=[REDACTED]", redacted) redacted = _URL_USERINFO_RE.sub(r"\1[REDACTED]:[REDACTED]@", redacted) redacted = _URL_QUERY_SECRET_RE.sub(r"\1[REDACTED]", redacted) diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index 109f724..97ef9ca 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -21,7 +21,7 @@ from src.providers.openai import OpenAIProvider from src.providers.openai_compatible import OpenAICompatibleProvider from src.providers.resources import AsyncGoogleResources, GoogleResources -from src.providers.volcengine import VolcengineProvider +from src.providers.volcengine import _ARK_BUILTIN_TOOL_ITEM_TYPES, VolcengineProvider from src.utils.config import ModelDetail from src.utils.security import ( find_sensitive_option_paths, @@ -6643,13 +6643,16 @@ def test_ark_dynamic_responses_create_allows_valid_request(): }, {"type": "mcp_call", "id": "c1", "name": "n", "arguments": "{}", "server_label": "s"}, {"type": "mcp_list_tools", "id": "m1", "server_label": "s", "tools": []}, - {"type": "reasoning", "id": "r1", "summary": []}, + {"type": "agent_tool_call", "id": "a1", "name": "agent", "status": "completed"}, ], ) def test_ark_builtin_tool_items_are_not_treated_as_malformed(item): """内置工具(web_search_call、mcp_call 等)的调用项既无正文也不是 function_call,但都是 SDK 输出项联合的正式成员,属正常中间态——没有工具 调用就不可能有后续轮次。判为「结构不符」会让这类响应无法处理。 + + ``reasoning`` 不在豁免之列:它不是工具调用,本轮有摘要时走 ``reasoning`` + 字段放行;空摘要且无正文、无工具调用时确实什么都没有,报错才对。 """ response = _ark_response([item]) @@ -6658,6 +6661,57 @@ def test_ark_builtin_tool_items_are_not_treated_as_malformed(item): assert result.text == "" +def test_ark_empty_reasoning_item_is_not_a_silent_success(): + """空摘要的 reasoning 项不构成有效响应。 + + 把它列入豁免会让「completed 但什么都没有」静默返回空成功,掩盖失败 + (项目规则禁止)。 + """ + response = _ark_response([{"type": "reasoning", "id": "r1", "summary": []}]) + + with pytest.raises(RuntimeError, match="未包含任何正文"): + VolcengineProvider._extract_result(response) + + +def test_ark_builtin_item_whitelist_covers_sdk_union(): + """白名单必须覆盖 SDK 输出项联合的全部成员(除已单独处理的)。 + + 手工维护的清单会随 SDK 演进漏掉新成员,那类响应会突然无法处理——这正是 + ``image_process``、``agent_tool_call`` 曾经的状态。这里对差集断言。 + """ + import typing + + from volcenginesdkarkruntime.types.responses.response import ResponseOutputItem + + def flatten(union, seen=None): + seen = seen or set() + found = [] + for member in typing.get_args(union): + if member in seen: + continue + seen.add(member) + if typing.get_origin(member) is typing.Union: + found.extend(flatten(member, seen)) + else: + found.append(member) + return found + + union_types = set() + for member in flatten(ResponseOutputItem): + type_field = getattr(member, "model_fields", {}).get("type") + if type_field is None: + continue + literals = typing.get_args(type_field.annotation) + if literals: + union_types.add(literals[0]) + + # ``message`` 由正文提取处理,``function_call`` 归入 tool_calls, + # ``reasoning`` 有摘要时经 reasoning 字段放行。 + handled = {"message", "function_call", "reasoning"} + + assert union_types - handled - _ARK_BUILTIN_TOOL_ITEM_TYPES == set() + + def test_ark_empty_completed_response_still_fails_loudly(): """放宽内置工具项后,真正空白的 completed 响应仍必须显式失败。""" response = _ark_response([]) @@ -7056,3 +7110,194 @@ def test_ark_responses_native_item_proxy_hides_implementation_slots(): assert internal not in dir(proxy), internal # 隐藏的是自动补全,不是访问能力。 assert proxy._value is provider._client.responses + + +# ── 回归:资源代理不能经私有名转发到底层 SDK ── + + +def test_native_resource_proxy_blocks_private_attribute_forwarding(): + """``__slots__`` 封闭了自有 ``__dict__``,但 ``__getattr__`` 会把私有名 + 转发给底层 SDK 节点——``proxy.__dict__["_client"]`` 能拿到未包装的原始 + 客户端,绕开全部凭证扫描与能力门禁。出口应是文档化的 ``.native``。 + """ + import httpx + from volcenginesdkarkruntime import Ark + + provider = object.__new__(VolcengineProvider) + provider._provider = "volcengine" + provider._model_name = "ark-model" + provider._protocol = "chat_completions" + provider._server_verified_protocols = frozenset() + provider._options = {} + object.__setattr__( + provider, + "_client", + Ark( + api_key="test-key", + base_url="https://ark.example.invalid/api/v3", + http_client=httpx.Client( + transport=httpx.MockTransport( + lambda _request: httpx.Response( + 200, json={"id": "r", "status": "completed", "output": []} + ) + ) + ), + ), + ) + + proxy = provider.resources.responses + + for private in ("__dict__", "_client", "_post", "_get", "_delete"): + with pytest.raises(AttributeError): + getattr(proxy, private) + # 文档化的出口仍可用。 + assert provider.resources.native is provider._client + + +def test_native_resource_proxy_dir_lists_only_public_resource_names(): + """``dir()`` 不能提示可直达原始 SDK 的私有通路。""" + provider = object.__new__(VolcengineProvider) + provider._provider = "volcengine" + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._server_verified_protocols = frozenset() + provider._options = {} + object.__setattr__( + provider, + "_client", + SimpleNamespace( + responses=SimpleNamespace(create=lambda **_kwargs: "remote", retrieve=lambda **_k: "r") + ), + ) + + listed = dir(provider.resources.responses) + + assert not [name for name in listed if name.startswith("_")], listed + assert "create" in listed + + +# ── 回归:caching 取值的大小写与空白变体 ── + + +@pytest.mark.parametrize( + "caching", + [ + {"type": "enabled"}, + {"type": "ENABLED"}, + {"type": "Enabled"}, + {"type": " enabled"}, + {"type": " ENABLED "}, + ], +) +def test_ark_responses_mutual_exclusion_normalizes_caching_type(caching): + """SDK 的 ``ResponseCaching.type`` 是类型注解、不做运行时校验, + ``"ENABLED"`` 会原样发到服务端。精确比较会让大小写变体绕过互斥检查。""" + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + with pytest.raises(ValueError, match="互斥"): + provider._build_responses_request( + CompletionRequest(prompt="hi", system_prompt="sys", caching=caching, stream=False) + ) + + +@pytest.mark.parametrize("caching", [{"type": "disabled"}, {"type": "DISABLED"}, {}]) +def test_ark_responses_mutual_exclusion_allows_non_enabled_caching(caching): + """未启用 caching 时 instructions 正常透传。""" + provider = object.__new__(VolcengineProvider) + provider._model_name = "ark-model" + provider._protocol = "responses" + provider._options = {"server_verified_protocols": ["responses"]} + + params = provider._build_responses_request( + CompletionRequest(prompt="hi", system_prompt="sys", caching=caching, stream=False) + ) + + assert params["instructions"] == "sys" + + +# ── 回归:动态路径的参数校验不按动词白名单 ── + + +def test_ark_dynamic_responses_validation_is_not_verb_based(): + """按 ``{"create", "generate"}`` 判断会漏掉 ``async_create`` 这类 SDK + 演进后新增的写法;应按参数里是否出现受校验字段判断。""" + resources, captured = _ark_provider_with_recording_client( + protocol="responses", verified=["responses"] + ) + # 模拟 SDK 演进后新增的动词:按动词白名单判断会漏掉它。 + resources.provider._client.responses.async_create = ( # type: ignore[attr-defined] + lambda **kwargs: captured.append(("responses.async_create", kwargs)) or "remote" + ) + + with pytest.raises(ValueError, match="互斥"): + resources.responses.async_create( + model="ark-model", input="x", instructions="sys", caching={"type": "enabled"} + ) + + assert captured == [] + + +# ── 回归:动态路径的能力映射要覆盖全部能力 ── + + +@pytest.mark.parametrize( + ("path", "capability"), + [ + ("deepseek.batches.create", "batches"), + ("deepseek.vector_stores.create", "vector_stores"), + ("deepseek.images.generate", "images"), + ("deepseek.containers.create", "containers"), + ("deepseek.audio.speech.create", "audio"), + ("deepseek.files.create", "files"), + # uploads 与 files 是两项独立能力,不能坍缩。 + ("deepseek.uploads.create", "uploads"), + ], +) +def test_openai_compatible_dynamic_path_resolves_capability(path, capability): + """只覆盖 responses/files 会让其余动态路径拿到 ``None`` 直接放行,而显式 + Facade 名会被拦——同一能力因书写形式不同而区别对待。""" + assert OpenAICompatibleProvider._resource_capability_for_path(path) == capability + + +# ── 回归:顶层 Ark 专属字段与带 role 的 Ark 专属块 ── + + +@pytest.mark.parametrize( + "message", + [ + {"image_pixel_limit": {"max_pixels": 1}}, + {"type": "message", "role": "user", "content": "hi", "image_pixel_limit": {"m": 1}}, + {"role": "user", "content": "hi", "image_pixel_limit": {"m": 1}}, + {"role": "user", "content": "x", "type": "input_audio", "audio_url": "u"}, + {"role": "system", "content": "x", "type": "input_video", "video_url": "u"}, + ], +) +def test_openai_responses_rejects_ark_only_fields_in_every_position(message): + """Ark 专属字段与 ``type``/``content``/``role`` 都无关:写成内容块、原生 + item 还是普通消息顶层,OpenAI 兼容渠道都不能发出。只查一条路径会留下旁路。 + """ + with pytest.raises(ValueError, match="不受|缺少"): + normalize_responses_input(None, None, [message], provider="openai") + + +@pytest.mark.parametrize("bad_type", [["input_text"], {"a": 1}, 123]) +def test_responses_content_type_must_be_hashable_string(bad_type): + """``type`` 是任意 JSON 值,直接做集合查找会抛 + ``TypeError: unhashable type``,把可诊断的类型错误变成崩溃。""" + with pytest.raises(ValueError, match="type 必须是非空字符串"): + normalize_responses_input(None, None, [{"type": bad_type, "text": "x"}], provider="openai") + + +def test_ark_responses_accepts_ark_only_fields(): + """补全校验后 Ark 渠道自身仍要放行。""" + converted = normalize_responses_input( + None, + None, + [{"type": "input_image", "image_url": "u", "image_pixel_limit": {"m": 1}}], + provider="ark", + ) + + assert converted[0]["image_pixel_limit"] == {"m": 1} diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index 64c7517..1997558 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -389,3 +389,95 @@ def test_connection_only_keys_are_deliberately_excluded_from_text_redaction(): assert key in SENSITIVE_OPTION_KEYS, key text = f"{key}=https://example.invalid/v1" assert redact_sensitive_text(text) == text, key + + +# ── 回归:值的形态约束不能收窄真凭证的取值域 ── + + +@pytest.mark.parametrize( + "value", + [ + "password: FakePwOnly", + "api_key: abcdefgh", + "token: mytoken", + "secret: abcdefgh", + "client_secret: abcdefgh", + "passwd: FakePwOnly", + "credential: abcdefgh", + ], +) +def test_security_redacts_short_alphabetic_credential_values(value): + """短且纯字母的值也是凭证。 + + 此前对全部键名统一加「≥12 字符或含数字/符号」的形态约束,使 + ``password: FakePwOnly`` 这类键值对整条漏检——那是比误判更严重的净漏检。 + 形态约束只该用于排除误判源,不该收窄真凭证的取值域。 + """ + assert redact_sensitive_text(value) != value + + +@pytest.mark.parametrize( + "value", + ["oauth: 2.0", "topsecret: 42", "sessiontoken: x1", "mytoken: abc12345", "xxtoken: 99"], +) +def test_security_does_not_flag_ordinary_identifiers_ending_in_credential_words(value): + """以凭证词结尾的普通标识符不能被从词中间切开。 + + ``is_sensitive_option_key`` 按 camelCase 边界切词,``topsecret`` 切出的是 + 单个词 ``topsecret``,判为**非敏感**;文本侧若不加界断言就会命中里面的 + ``secret``,而边界校验用「值是否被改写」判断,于是合法配置被误拒。 + """ + assert redact_sensitive_text(value) == value + + +# ── 回归:掩码规则的性能与尾部裁剪 ── + + +def test_security_masked_rule_stays_linear_on_long_trailing_runs(): + """掩码尾部不能引入二次回溯。 + + ``redact_sensitive_text`` 挂在每条日志的 formatter 上,一个回显掩码密钥 + 前缀、后面跟长 token 的错误体就能拖住进程。逐字符前瞻的写法在尾部无冒号 + 时退化成 O(n²)。 + """ + import time + + text = "sk-abc***" + "deadbeef" * 500 + + start = time.perf_counter() + redact_sensitive_text(text) + elapsed = time.perf_counter() - start + + # 线性实现约 0.1ms;二次实现约 500ms。留足余量避免 CI 抖动误报。 + assert elapsed < 0.1, f"耗时 {elapsed * 1000:.1f}ms,疑似二次回溯" + + +def test_security_masked_rule_keeps_adjacent_key_name_for_key_value_rule(): + """掩码尾部混进的敏感键名要留给键值规则处理,否则值变明文。""" + secret = "SECRETVALUE1234567890" + redacted = redact_sensitive_text(f"sk-abc***token={secret}") + + assert secret not in redacted + assert redacted == "[REDACTED]token=[REDACTED]" + + +@pytest.mark.parametrize( + "text", + ["sk-abc***defghijkl: boom", "sess-abc***tail: unauthorized", "sk-abc***xyz: boom"], +) +def test_security_masked_rule_consumes_visible_trailing_fragment(text): + """尾部可见片段不是键名时,应随掩码一起吃掉,不能留在 ``[REDACTED]`` 之后。""" + assert redact_sensitive_text(text).startswith("[REDACTED]") + + +def test_security_bearer_rule_redacts_alphabetic_token_adjacent_to_cjk(): + """``Bearer`` 分支的界断言此前是 ``\\b``,在中文两侧不成立。 + + 这条断言此前无测试锚定:CJK 用例都由 ``_OPENAI_KEY_RE`` 等兜底满足, + 回退 bearer 那一行测试仍全绿。 + """ + token = "abcdefghijklmnopqrst" + rendered = redact_sensitive_text(f"鉴权失败Bearer {token}") + + assert token not in rendered + assert "[REDACTED]" in rendered From 54af014845bef2c51e41af08e3328fb4640cf2d5 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 15:34:25 +0800 Subject: [PATCH 13/31] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20CHANGELOG=20?= =?UTF-8?q?=E5=88=B0=E6=9C=AC=E8=BD=AE=E5=AE=A1=E6=9F=A5=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39276a6..f61a5a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,9 @@ - 收紧动态资源树的能力门禁:此前按路径分段判断时只认复数 `responses`,而 `input_items`(实际请求 `/responses/{id}/input_items`)不含该分段,在未登记 `server_verified_protocols` 的渠道上也能把请求发到远端;同时动态路径只做能力检查、不执行 Facade 的参数校验,可带着互斥参数直接发出。现在两条路径共用同一套校验,`extra_body` 里的 `instructions`/`caching` 同样参与互斥检查。 - 顶层 Responses item 形态(`{"type": "input_audio", ...}`)补齐变体校验:此前只在缺少 `content` 键时检查,加一个无关的 `content` 键即可改走消息分支绕过全部校验;现在按 `type` 分流并复用同一套内容块实现。 - 修复 Responses 流式工具调用的 `id` 语义:`id` 此前在 delta 事件取输出项 ID、在 done 事件取调用 ID、合并时又互相覆盖,同一轮工具调用在 delta 与 completed 事件里得到不同的值,调用方按 `tool_call["id"]` 回填 `role=tool` 时会配不上。现在与非流式路径统一取调用标识符。 -- 放宽 Ark Responses 的失败判定:内置工具调用项(`web_search_call`、`mcp_call`、`mcp_list_tools`、空摘要的 `reasoning`)都是 SDK 输出项联合的正式成员,属正常中间态,此前被误判为「响应结构与预期不符」而无法处理。 +- 放宽 Ark Responses 的失败判定:内置工具调用项(`web_search_call`、`mcp_call`、`mcp_list_tools`、`image_process`、`agent_tool_call` 等)都是 SDK 输出项联合的正式成员,且都是需要后续轮次的中间态,此前被误判为「响应结构与预期不符」而无法处理。空摘要的 `reasoning` 不在豁免之列——它不是工具调用,豁免会让「completed 但什么都没有」静默返回空成功。 +- 动态资源路径的能力门禁补齐:兼容渠道的路径映射此前只覆盖 responses/files,其余资源树(batches、vector_stores、images 等)拿到空能力直接放行,而显式 Facade 名会被拦;Ark 的动态路径按动词白名单决定是否做参数校验,漏掉 `async_create` 这类写法。两者都改为按资源分段与参数内容判断。 +- 资源代理不再经私有名转发到底层 SDK:`proxy.__dict__["_client"]` 此前能拿到未包装的原始客户端,绕开全部凭证扫描与能力门禁;`dir()` 也把这些通路提示给使用者。出口只保留文档化的 `.native`。 - Embedding 区分文档与查询任务类型:Google 使用 `RETRIEVAL_DOCUMENT` 与 `RETRIEVAL_QUERY`。 ### 安全 @@ -28,6 +30,8 @@ - 修复脱敏日志 formatter 的缓存泄漏:`logging.Formatter` 会把格式化后的 traceback 缓存在 `record.exc_text` 上供后续 handler 复用,先格式化后脱敏会让同一 logger 上的非脱敏 handler 输出原始凭证;现在脱敏结果会写回缓存。 - 文本脱敏的键名识别与配置边界对齐:此前词表更窄,且键名前要求非字母数字字符,`dbPassword`、`myApiKey` 这类驼峰键在配置边界被拒绝、在日志里却明文输出。同时给值加上形态约束,避免 `auth: none`、`cookie: enabled` 这类普通文本被误判为凭证而让合法 options 在边界被拒。 - 词边界改用「非 ASCII 字母数字」而非 `\b`:中文属 `\w`,`\b` 在 `密钥sk-...` 两侧都不成立,国产网关(SiliconFlow、Ark、DashScope)的中文错误消息此前会让密钥整串落进终端与日志。 +- 键名识别改用「非字母数字或 camelCase 边界」:既让 `dbPassword`、`myApiKey` 这类驼峰键脱敏,又不把 `topsecret`、`sessiontoken` 从词中间切开(配置边界按 camelCase 切词判它们非敏感,切开会导致合法配置被误拒)。排除误判源改用非凭证字面量枚举,而不是值的长度/字符构成——后者会把 `password: FakePwOnly` 这类短纯字母凭证一并放过。 +- 修复掩码规则的二次回溯:尾部前瞻逐字符重复扫描剩余串,在尾部无冒号时退化成 O(n²)(4000 字符的掩码密钥前缀加长 token 耗时 1.4 秒)。该函数挂在每条日志的 formatter 上,一个回显错误体的网关响应即可拖住进程。 - 配置校验失败不再把凭证交给解释器默认 handler:`Settings()` 在 import 期就被调用(`log_manager.get_module_logger`),早于任何入口的 `try`,pydantic 默认渲染会把 `input_value` 明文打印。现在在 `get_settings` 边界重抛已脱敏的消息,并给 `run_cli` 补上覆盖整个启动阶段的异常边界。 - 兼容渠道不再被当作官方 OpenAI 端点放行 SDK 专属资源;仅精确匹配 `https://api.openai.com/v1` 时按 SDK 契约放行。 - 收紧失败语义:凭证、SDK 或远端调用失败时显式报错,不再以空 embedding、零分 rerank 或占位回答掩盖失败。 @@ -44,7 +48,7 @@ ### 测试与文档 -- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 958。新增断言均经红绿验证(回退源码后对应测试变红),并替换了两处无区分度的旧断言:URL query 脱敏断言此前用的长值由另一条规则满足,Ark 响应夹具此前伪造了 SDK 不存在的 `output_text` 字段。 +- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1005。新增断言均经红绿验证(回退源码后对应测试变红),并替换了两处无区分度的旧断言:URL query 脱敏断言此前用的长值由另一条规则满足,Ark 响应夹具此前伪造了 SDK 不存在的 `output_text` 字段。 - 为活动快照的 embedding 兼容性检测补充回归测试:快照记录的 embedding provider 或模型名与当前运行配置不一致时必须显式失败,避免用错向量空间后静默产出错误检索结果。 - 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置;新增 `.github/workflows/quality.yml`,在 PR 与 `main` 推送时执行格式化检查、lint、类型检查、安全扫描与完整测试,此前这些工具只在本地手动运行。 - 全仓库应用 `ruff format`(行宽 100、双引号、4 空格),此前未配置 formatter,`main.py`、`src/`、`scripts/`、`tests/` 中存在混用单引号、行尾空白和手工对齐等不一致;格式化只改表示不改语义,已用 AST 比对确认语法树等价,`AGENTS.md` 的质量检查与风格段落同步更新。 From 5ece7ba81669e4e21575e7e815d539f4018f0c77 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 15:47:57 +0800 Subject: [PATCH 14/31] =?UTF-8?q?test:=20=E7=94=A8=E5=90=88=E6=88=90?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E6=9B=BF=E6=8D=A2=E4=BC=9A=E8=A7=A6=E5=8F=91?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E6=89=AB=E6=8F=8F=E7=9A=84=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 密钥扫描器把 ``password: huntertwo``、``passwd: letmein`` 这类已知弱口令 判为 Generic Password 并告警。它们是测试用的假值,与真实凭证无关(已核对 本地 .env 无重合、全历史无真实令牌样式),但告警会留在提交历史里,且掩盖 后续真实告警。 测试要覆盖的属性是「短且纯字母」,与具体取值无关,改用明显合成的标记。 文档与注释里的同一处示例一并替换。 Co-Authored-By: Claude Opus 5 (1M context) --- tests/providers/test_security_boundaries.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index 1997558..20db835 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -397,13 +397,16 @@ def test_connection_only_keys_are_deliberately_excluded_from_text_redaction(): @pytest.mark.parametrize( "value", [ + # 值必须「短且纯字母」——那是本用例要覆盖的属性(此前长度/字符构成 + # 约束会把这类值放过)。用明显合成的标记而非常见口令词:后者会触发 + # 仓库的密钥扫描告警,把提交历史染上无法消除的误报。 "password: FakePwOnly", - "api_key: abcdefgh", - "token: mytoken", - "secret: abcdefgh", - "client_secret: abcdefgh", + "api_key: FakeKeyOnly", + "token: FakeTokenOnly", + "secret: FakeSecretOnly", + "client_secret: FakeSecretOnly", "passwd: FakePwOnly", - "credential: abcdefgh", + "credential: FakeCredOnly", ], ) def test_security_redacts_short_alphabetic_credential_values(value): From 101fb14f28ae5edfa87ad61c1f6cd2a646813b7d Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 16:00:28 +0800 Subject: [PATCH 15/31] =?UTF-8?q?test:=20=E7=94=A8=E5=B8=A6=20FAKE=20?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E7=9A=84=E5=80=BC=E6=9B=BF=E6=8D=A2=E5=AF=86?= =?UTF-8?q?=E9=92=A5=E6=A0=BC=E5=BC=8F=E7=9A=84=E6=B5=8B=E8=AF=95=E5=A4=B9?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试里的 ``sk-proj-AbCdEfGh...``、``AIzaSyAbCdEf...`` 虽然内容明显合成, 但符合 OpenAI 与 Google API Key 的真实格式,容易被密钥扫描器按格式规则命中。 改用 ``FAKE0000`` 重复的形态:正则仍能匹配(测试要的是格式),一眼可辨为假值。 Co-Authored-By: Claude Opus 5 (1M context) From 02ea25d1226678dc65d078ffe26960252ec83f4f Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 17:18:23 +0800 Subject: [PATCH 16/31] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=E8=84=B1=E6=95=8F=E6=BC=8F?= =?UTF-8?q?=E6=A3=80=E3=80=81=E9=97=A8=E7=A6=81=E8=AF=AF=E6=8B=A6=E4=B8=8E?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E6=98=A0=E5=B0=84=E4=B8=8D=E4=B8=80=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多轮审查发现的缺陷,均已在对应提交上复现并红绿验证。 脱敏边界(security.py) - 掩码规则吞掉键值对键名后留下孤儿值:sk-abc***xxxtoken= 里的键名 按 camelCase 切词判非敏感,键值规则不会接手,而掩码已把键名吃掉,值失去 锚点后明文落进日志。掩码匹配现在把键值尾部一并纳入,只保留键名作上下文。 - 替换函数里的二次回溯:尾部游程用无锚点的 re.search(r"[A-Za-z0-9_-]+$") 查找,在每个起点重试 +$;尾部以 . 这类掩码占位符收尾时游程够不到 $,退化 成 O(n²)(32010 字符 9.6 秒)。改为单遍扫描。 - 非凭证字面量枚举改为大小写不敏感,auth: None 这类写法此前被判成凭证, 使合法配置在边界被误拒。 - 界断言补齐第三条驼峰分支(大写串接小写词),HTTPBearer/HTTPSSecret 此前 在配置边界判敏感、在文本里明文输出。 Provider 门禁 - Ark 动态路径的参数校验触发集含 extra_body,而它是 retrieve/delete/list 的 合法参数,使只读操作被要求提供 model 与 input——Facade 可用而动态路径不可用。 - 兼容渠道路径映射的 moderation 用了单数,SDK 属性是复数 moderations, 导致 resources.moderations.create 拿到空能力直接放行。 - 段映射列出五项官方声明集之外的能力名,把官方端点从放行变成显式报错, 而这些资源在 capabilities 里声明为可用。 - Google 实验性资源门禁只认显式 Facade 名,动态点分路径全部落空放行。 - 顶层 Ark 专属字段只拦 image_pixel_limit,带一个无关的 content 键即可 绕开类型分流,input_audio/input_video/audio_url/video_url 原样发给不支持的端点。 新增 28 项回归测试,测试总数 1033。 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 10 +- src/providers/__base__/model_provider.py | 8 +- src/providers/google.py | 13 ++- src/providers/openai_compatible.py | 17 +-- src/providers/volcengine.py | 11 +- src/utils/security.py | 46 ++++++--- tests/providers/test_sdk_capabilities.py | 109 ++++++++++++++++++++ tests/providers/test_security_boundaries.py | 58 +++++++++-- 8 files changed, 239 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f61a5a8..e217c8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,10 @@ - 顶层 Responses item 形态(`{"type": "input_audio", ...}`)补齐变体校验:此前只在缺少 `content` 键时检查,加一个无关的 `content` 键即可改走消息分支绕过全部校验;现在按 `type` 分流并复用同一套内容块实现。 - 修复 Responses 流式工具调用的 `id` 语义:`id` 此前在 delta 事件取输出项 ID、在 done 事件取调用 ID、合并时又互相覆盖,同一轮工具调用在 delta 与 completed 事件里得到不同的值,调用方按 `tool_call["id"]` 回填 `role=tool` 时会配不上。现在与非流式路径统一取调用标识符。 - 放宽 Ark Responses 的失败判定:内置工具调用项(`web_search_call`、`mcp_call`、`mcp_list_tools`、`image_process`、`agent_tool_call` 等)都是 SDK 输出项联合的正式成员,且都是需要后续轮次的中间态,此前被误判为「响应结构与预期不符」而无法处理。空摘要的 `reasoning` 不在豁免之列——它不是工具调用,豁免会让「completed 但什么都没有」静默返回空成功。 -- 动态资源路径的能力门禁补齐:兼容渠道的路径映射此前只覆盖 responses/files,其余资源树(batches、vector_stores、images 等)拿到空能力直接放行,而显式 Facade 名会被拦;Ark 的动态路径按动词白名单决定是否做参数校验,漏掉 `async_create` 这类写法。两者都改为按资源分段与参数内容判断。 +- 动态资源路径的能力门禁补齐:兼容渠道的路径映射此前只覆盖 responses/files,其余资源树(batches、vector_stores、images 等)拿到空能力直接放行,而显式 Facade 名会被拦;Ark 的动态路径按动词白名单决定是否做参数校验,漏掉 `async_create` 这类写法。两者都改为按资源分段与参数内容判断。判断依据用「参数里是否出现受校验字段」而不是动词,`extra_body` 不在此列——它是 retrieve/delete/list 的合法参数,放进触发集会把只读操作误判为创建请求、要求提供 `model` 与 `input`,使 Facade 可用而动态路径不可用。 - 资源代理不再经私有名转发到底层 SDK:`proxy.__dict__["_client"]` 此前能拿到未包装的原始客户端,绕开全部凭证扫描与能力门禁;`dir()` 也把这些通路提示给使用者。出口只保留文档化的 `.native`。 +- 修正动态资源路径的能力映射与官方声明集不一致:SDK 客户端的属性是复数 `moderations`,段映射只写单数会让 `resources.moderations.create` 拿到空能力直接放行,而 `create_moderation` 会被拦;同时段映射列出了 `skills`/`realtime`/`webhooks`/`admin`/`content_provenance_checks` 五项官方声明集之外的能力名,把官方端点从放行变成显式报错——而这些资源在 `capabilities` 里声明为可用。段映射现在限定在声明集内。 +- Google 的实验性资源门禁此前只认显式 Facade 名:动态路径是点分形式(`google.interactions.create`),资源名是独立分段,按前缀匹配全部落空、直接放行——用户拿一个不含该资源的客户端走 `resources.interactions` 就能绕开。改为同时按路径分段匹配。 - Embedding 区分文档与查询任务类型:Google 使用 `RETRIEVAL_DOCUMENT` 与 `RETRIEVAL_QUERY`。 ### 安全 @@ -32,6 +34,10 @@ - 词边界改用「非 ASCII 字母数字」而非 `\b`:中文属 `\w`,`\b` 在 `密钥sk-...` 两侧都不成立,国产网关(SiliconFlow、Ark、DashScope)的中文错误消息此前会让密钥整串落进终端与日志。 - 键名识别改用「非字母数字或 camelCase 边界」:既让 `dbPassword`、`myApiKey` 这类驼峰键脱敏,又不把 `topsecret`、`sessiontoken` 从词中间切开(配置边界按 camelCase 切词判它们非敏感,切开会导致合法配置被误拒)。排除误判源改用非凭证字面量枚举,而不是值的长度/字符构成——后者会把 `password: FakePwOnly` 这类短纯字母凭证一并放过。 - 修复掩码规则的二次回溯:尾部前瞻逐字符重复扫描剩余串,在尾部无冒号时退化成 O(n²)(4000 字符的掩码密钥前缀加长 token 耗时 1.4 秒)。该函数挂在每条日志的 formatter 上,一个回显错误体的网关响应即可拖住进程。 +- 掩码规则不再吞掉键值对的键名后留下孤儿值:`sk-abc***xxxtoken=` 里的键名不是敏感键(按 camelCase 切词判非敏感),键值规则不会接手,而掩码又已把键名吃掉,值失去锚点后明文落进日志。现在掩码匹配把键值尾部一并纳入,只保留键名作为可读上下文。 +- 修复替换函数里的二次回溯:尾部游程查找用无锚点的 `re.search(r"[A-Za-z0-9_-]+$")`,在每个起点重试 `+$`;尾部以 `.` 这类掩码占位符收尾时游程够不到 `$`,退化成 O(n²)(32010 字符 9.6 秒,4010 字符 86ms)。仓库原有线性测试的用例恰好无尾随字符,所以只跑 1.2ms 就通过、没能拦住。改为单遍扫描,并给该测试补上以掩码占位符收尾的形态。 +- 非凭证字面量枚举改为大小写不敏感:`auth: None`、`token: Null`、`cookie: Enabled` 此前不被识别为状态字面量而判成凭证,`find_sensitive_option_paths` 据此把合法配置在边界误拒。 +- 文本脱敏的界断言补齐第三条驼峰分支:`is_sensitive_option_key` 的切词含「大写串接小写词」,文本侧只实现了「非字母数字」与「小写接大写」两条,`HTTPBearer`/`HTTPSSecret` 这类首字母缩写键在配置边界判敏感、在日志里却明文输出。 - 配置校验失败不再把凭证交给解释器默认 handler:`Settings()` 在 import 期就被调用(`log_manager.get_module_logger`),早于任何入口的 `try`,pydantic 默认渲染会把 `input_value` 明文打印。现在在 `get_settings` 边界重抛已脱敏的消息,并给 `run_cli` 补上覆盖整个启动阶段的异常边界。 - 兼容渠道不再被当作官方 OpenAI 端点放行 SDK 专属资源;仅精确匹配 `https://api.openai.com/v1` 时按 SDK 契约放行。 - 收紧失败语义:凭证、SDK 或远端调用失败时显式报错,不再以空 embedding、零分 rerank 或占位回答掩盖失败。 @@ -48,7 +54,7 @@ ### 测试与文档 -- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1005。新增断言均经红绿验证(回退源码后对应测试变红),并替换了两处无区分度的旧断言:URL query 脱敏断言此前用的长值由另一条规则满足,Ark 响应夹具此前伪造了 SDK 不存在的 `output_text` 字段。 +- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1033。新增断言均经红绿验证(回退源码后对应测试变红),并替换了两处无区分度的旧断言:URL query 脱敏断言此前用的长值由另一条规则满足,Ark 响应夹具此前伪造了 SDK 不存在的 `output_text` 字段。 - 为活动快照的 embedding 兼容性检测补充回归测试:快照记录的 embedding provider 或模型名与当前运行配置不一致时必须显式失败,避免用错向量空间后静默产出错误检索结果。 - 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置;新增 `.github/workflows/quality.yml`,在 PR 与 `main` 推送时执行格式化检查、lint、类型检查、安全扫描与完整测试,此前这些工具只在本地手动运行。 - 全仓库应用 `ruff format`(行宽 100、双引号、4 空格),此前未配置 formatter,`main.py`、`src/`、`scripts/`、`tests/` 中存在混用单引号、行尾空白和手工对齐等不一致;格式化只改表示不改语义,已用 AST 比对确认语法树等价,`AGENTS.md` 的质量检查与风格段落同步更新。 diff --git a/src/providers/__base__/model_provider.py b/src/providers/__base__/model_provider.py index 7a4846c..8ef6979 100644 --- a/src/providers/__base__/model_provider.py +++ b/src/providers/__base__/model_provider.py @@ -553,10 +553,16 @@ def _reject_unsupported_responses_top_level_fields( """拒绝 OpenAI 兼容渠道发出 Ark 专属的顶层字段。 这类字段与 item 类型无关:无论写成内容块、原生 item 还是普通消息的顶层 - 字段,OpenAI Responses 都不接受。只检查内容块路径会留下旁路。 + 字段,OpenAI Responses 都不接受。只检查内容块路径会留下旁路—— + ``{"role": "user", "content": "hi", "input_audio": {...}}`` 带一个无关的 + ``content`` 键就绕开了 ``_normalize_responses_native_item`` 的类型分流, + 请求体里原样带上 Ark 专属块发给不支持它的端点。 """ if _responses_provider_variant(provider) == "ark": return + unsupported = sorted(_ARK_ONLY_RESPONSES_ITEM_TYPES.intersection(item)) + if unsupported: + raise ValueError(f"{location}.{unsupported[0]} 不受 OpenAI Responses SDK 支持。") if "image_pixel_limit" in item: raise ValueError(f"{location}.image_pixel_limit 不受 OpenAI Responses SDK 支持。") diff --git a/src/providers/google.py b/src/providers/google.py index d7908a8..28bf70b 100644 --- a/src/providers/google.py +++ b/src/providers/google.py @@ -403,9 +403,20 @@ def _require_provider_resource( """把实验性资源的版本差异转换成明确的能力错误。""" name = method_name.removeprefix("async_") resource_name: str | None = None + # 显式 Facade 名(``create_interaction``)与动态资源树路径 + # (``google.interactions.create``)形状不同:前者把资源名嵌在方法名里, + # 后者是点分路径,资源名是一个独立分段。只按 ``startswith``/``_singular`` + # 匹配会让所有动态路径都落到 ``None`` 直接放行——用户拿一个不含该资源的 + # 客户端走 ``resources.interactions`` 就能绕开这道门禁。 + segments = name.split(".") for candidate in self._EXPERIMENTAL_RESOURCE_LABELS: singular = candidate.removesuffix("s") - if name.startswith(singular) or f"_{singular}" in name: + if ( + name.startswith(singular) + or f"_{singular}" in name + or candidate in segments + or singular in segments + ): resource_name = candidate break if resource_name is None: diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index 576f9ca..f1754e1 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -605,6 +605,12 @@ def _resource_capability_for_method(method_name: str) -> str | None: # # ``uploads`` 与 ``files`` 是两项独立能力(SDK 里也分属不同资源), # 不能坍缩成同一个名字。 + # + # 这里的键必须落在官方声明集 ``_OFFICIAL_OPENAI_RESOURCE_CAPABILITIES`` + # 内:段映射一旦给出声明集之外的能力名,官方端点会从「放行」变成 + # ``NotImplementedError``——而 ``OpenAIProvider.capabilities`` 里那些 + # 资源(skills/realtime/webhooks/admin/content_provenance_checks)是 + # 声明为可用的,用户看到可用、调用却被拦。 _RESOURCE_SEGMENTS_TO_CAPABILITY: ClassVar[Mapping[str, str]] = { "responses": "responses", "input_items": "responses", @@ -614,7 +620,11 @@ def _resource_capability_for_method(method_name: str) -> str | None: "batches": "batches", "vector_stores": "vector_stores", "models": "models", - "moderation": "moderation", + # SDK 客户端的属性是复数 ``moderations``(``client.moderations``), + # 而显式 Facade 名 ``create_moderation`` 解析出的能力名是单数。两个 + # 拼写都要映射到同一能力,否则 ``resources.moderations.create`` 拿到 + # capability=None 直接放行,而 ``create_moderation`` 会被拦。 + "moderations": "moderation", "images": "images", "audio": "audio", "videos": "videos", @@ -622,11 +632,6 @@ def _resource_capability_for_method(method_name: str) -> str | None: "containers": "containers", "fine_tuning": "fine_tuning", "evals": "evals", - "skills": "skills", - "realtime": "realtime", - "webhooks": "webhooks", - "admin": "admin", - "content_provenance_checks": "content_provenance_checks", } @classmethod diff --git a/src/providers/volcengine.py b/src/providers/volcengine.py index 123fed4..88e8da6 100644 --- a/src/providers/volcengine.py +++ b/src/providers/volcengine.py @@ -647,8 +647,17 @@ def _normalize_verified_protocols(cls, configured: Any) -> frozenset[str]: # 因此用精确集合而不是子串匹配。 # 这些字段出现即说明调用意图是「创建/发起 Responses 请求」,无论方法名 # 是什么。用字段而非动词判断,避免 SDK 新增入口时漏检。 + # 触发参数校验的字段。``extra_body`` 不在此列:它是 retrieve/delete/list + # 等非创建调用的**合法**参数(见 ``_ARK_RESPONSES_RETRIEVE_KEYS``), + # 出现在这些调用里不代表要执行创建校验。放进触发集会让 + # ``resources.responses.retrieve("r1", extra_body={...})`` 被要求提供 + # ``model`` 与 ``input``,而同样的调用走 Facade 是通过的——动态路径与 + # Facade 的新不对称(方向相反:动态路径过严)。 + # + # ``input``/``model`` 作为触发器是安全的:``create`` 二者皆必填,任何 + # 带它们的调用本就该走创建校验,无法被规避。 _ARK_RESPONSES_VALIDATED_KWARGS = frozenset( - {"input", "instructions", "caching", "model", "tools", "extra_body"} + {"input", "instructions", "caching", "model", "tools"} ) _ARK_RESPONSES_SUBRESOURCES = frozenset( diff --git a/src/utils/security.py b/src/utils/security.py index 97c7c73..115f19e 100644 --- a/src/utils/security.py +++ b/src/utils/security.py @@ -167,7 +167,7 @@ # ``(?<=[a-z0-9])(?=[A-Z])`` 的 ``[A-Z]`` 会匹配任意大小写字母,``oauth`` # 里的 ``auth``(前一个字符是 ``o``)会被当成驼峰边界。忽略大小写只作用于 # 键名本身(``(?i:...)`` 局部开启)。 -_CREDENTIAL_KEY_HEAD = r"(?:(? str: _BARE_KEYWORD_TEXT_RE = re.compile( _key_value_pattern( _BARE_CREDENTIAL_KEYWORD_PATTERN, - r"(?!(?:" + _NON_CREDENTIAL_VALUE + r")(?![A-Za-z0-9._~+/=-]))", + r"(?!(?i:" + _NON_CREDENTIAL_VALUE + r")(?![A-Za-z0-9._~+/=-]))", ) ) _URL_USERINFO_RE = re.compile(r"(?i)(https?://)([^\s/@:]+):([^\s/@]+)@") @@ -256,30 +256,44 @@ def _key_value_pattern(keys: str, value_prefix: str = "") -> str: # 从 0.01ms 涨到 11ms,4000 字符时 1.4 秒。而 redact_sensitive_text 挂在 # 每条日志的 formatter 上,一个回显掩码密钥前缀加长 token 的错误体就能 # 拖住进程。 - r"(?i)(?``。 + # 键名本身可能不是敏感键(``xxxtoken`` 按 camelCase 切词判非敏感), + # 键值规则不会接手,值就会明文落进日志。这里把键值尾部一并纳入匹配, + # 由替换函数决定如何脱敏——掩码前缀已表明它是凭证的可见尾部。 + # 尾随可选组不引入回溯:``=`` 不在前一个字符类里,引擎无需回退。 + r"(?:(=)" + _ANY_VALUE_SHAPE + r")?" ) def _redact_masked_credential(match: re.Match[str]) -> str: - """替换掩码凭证,但把混进尾部的敏感键名留给键值规则处理。 + """替换掩码凭证,并处理混进尾部的键名与值。 - ``sk-abc***token=`` 里的 ``token`` 若被掩码整体吃掉,后面的 - ``=`` 就失去锚点,值会从脱敏变成明文(净漏检)。这里从尾部 - 游程中找出最长的敏感键名后缀并保留,让 ``_KEY_VALUE_TEXT_RE`` 接续 - 处理。 + 尾部游程后跟 ``=`` 时(``sk-abc***xxxtoken=``),游程是键值对的 + 键名、后面是它的值;键名可能不是敏感键,键值规则不会接手,因此这里把 + 值一并脱敏,只保留键名作为可读上下文。 - 只保留**完整键名**(能通过 ``is_sensitive_option_key``)的后缀:掩码 - 尾部本身可能就是可见片段(``sk-abc***xyz``),不能整段留下。 + 没有分隔符时,游程可能是凭证的可见片段(``sk-abc***xyz``),不能整段 + 留下;但敏感键名(``token``/``myApiKey``)要保留,让 + ``_KEY_VALUE_TEXT_RE`` 接续处理 ``sk-abc***token: `` 这类写法。 """ - text = match.group(0) - # 键名是尾部那段连续的键名字符(``*``/``.``/``…`` 等掩码占位符不在其中), - # 整段判断是否为敏感键:``token``/``myApiKey`` 命中并保留, + # 键名游程由正则的 ``([A-Za-z0-9_.*-]*)`` 捕获(``*``/``.``/``…`` 等掩码 + # 占位符不在其中),整段判断是否为敏感键:``token``/``myApiKey`` 命中并保留, # ``defghijkl``/``xyz`` 这类可见片段不命中,随掩码一起吃掉。 # 不用「从某处截断取后缀」:``sk`` 本身就是火山凭证键名,从 ``s`` 起算会 # 把 ``sk-abc***`` 切碎。 - trailing = re.search(r"[A-Za-z0-9_-]+$", text) - if trailing is not None and is_sensitive_option_key(trailing.group(0)): - return "[REDACTED]" + trailing.group(0) + run = match.group(1) + # 键值尾部:游程是键名,后面的值同样属于凭证的可见尾部,一并脱敏。 + # 这里不能只保留键名交给 ``_KEY_VALUE_TEXT_RE``——键名可能不是敏感键 + # (``xxxtoken``/``oauth``),那条规则不会接手,值就明文落进日志。 + if match.group(2) is not None: + return "[REDACTED]" + run + "=[REDACTED]" + # 没有分隔符时游程可能是凭证的可见片段(``sk-abc***xyz``),不能整段留下; + # 但敏感键名(``token``/``myApiKey``)要保留,让键值规则接续处理 + # ``sk-abc***token: `` 这类冒号分隔的写法。 + if run and is_sensitive_option_key(run): + return "[REDACTED]" + run return "[REDACTED]" diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index 97ef9ca..d49e083 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -7262,6 +7262,115 @@ def test_openai_compatible_dynamic_path_resolves_capability(path, capability): assert OpenAICompatibleProvider._resource_capability_for_path(path) == capability +def test_openai_compatible_dynamic_path_moderations_matches_explicit_name(): + """SDK 客户端的属性是复数 ``moderations``,而显式 Facade 名 + ``create_moderation`` 解析出的能力名是单数。段映射只写单数时 + ``resources.moderations.create`` 拿到 ``None`` 直接放行,同一能力因 + 书写形式不同而区别对待。 + """ + assert OpenAICompatibleProvider._resource_capability_for_path( + "deepseek.moderations.create" + ) == ("moderation") + assert OpenAICompatibleProvider._resource_capability_for_method("create_moderation") == ( + "moderation" + ) + + +def test_openai_compatible_segment_map_stays_within_declared_capabilities(): + """段映射给出声明集之外的能力名,会把官方端点从「放行」变成 + ``NotImplementedError``——而 ``OpenAIProvider.capabilities`` 里那些资源 + (skills/realtime/webhooks/admin/content_provenance_checks)声明为可用, + 用户看到可用、调用却被拦。 + """ + declared = OpenAICompatibleProvider._OFFICIAL_OPENAI_RESOURCE_CAPABILITIES + # ``responses`` 走 ``_require_responses_resource`` 专用分支,不受声明集约束。 + extra = set(OpenAICompatibleProvider._RESOURCE_SEGMENTS_TO_CAPABILITY.values()) - set(declared) + assert extra == {"responses"}, extra + + +@pytest.mark.parametrize( + "path", + [ + "openai.skills.list", + "openai.realtime.client_secrets.create", + "openai.webhooks.list", + "openai.admin.api_keys.list", + "openai.content_provenance_checks.create", + ], +) +def test_official_openai_declared_resources_are_not_blocked_by_dynamic_path(path): + """``capabilities`` 声明这些资源可用,动态路径就不能拦下它们。""" + provider = object.__new__(OpenAIProvider) + provider._provider = "openai" + provider._require_provider_resource(path) + + +def test_ark_dynamic_retrieve_with_extra_body_is_not_treated_as_create(): + """``extra_body`` 是 retrieve/delete/list 的合法参数,不该触发创建校验。 + + 把它放进触发集会让 ``resources.responses.retrieve("r1", extra_body={...})`` + 被要求提供 ``model`` 与 ``input``,而同样的调用走 Facade 是通过的—— + 动态路径与 Facade 的新不对称。 + """ + provider = object.__new__(VolcengineProvider) + provider._protocol = "responses" + provider._server_verified_protocols = ("responses",) + + provider._require_provider_resource("volcengine.responses.retrieve", {"extra_body": {"a": 1}}) + provider._require_provider_resource("volcengine.responses.delete", {"extra_body": {"a": 1}}) + + +def test_ark_dynamic_create_still_rejects_conflicting_instructions_and_caching(): + """去掉 ``extra_body`` 触发器不能削弱创建路径的互斥校验。""" + provider = object.__new__(VolcengineProvider) + provider._protocol = "responses" + provider._server_verified_protocols = ("responses",) + + with pytest.raises(ValueError, match="instructions"): + provider._require_provider_resource( + "volcengine.responses.create", + {"model": "m", "input": "x", "instructions": "sys", "caching": {"type": "ENABLED"}}, + ) + + +@pytest.mark.parametrize( + "path", + [ + "google.interactions.create", + "google.agents.list", + "google.webhooks.create", + "google.environments.get", + "google.triggers.list", + ], +) +def test_google_dynamic_experimental_paths_are_gated(path): + """动态路径是点分形式,资源名是独立分段;只按 ``startswith``/``_singular`` + 匹配会让所有动态路径落到 ``None`` 直接放行——用户拿一个不含该资源的客户端 + 走 ``resources.interactions`` 就能绕开这道门禁。 + """ + provider = object.__new__(GoogleProvider) + provider._get_client = lambda: SimpleNamespace() + + with pytest.raises(NotImplementedError, match="(?i)资源"): + provider._require_provider_resource(path) + + +@pytest.mark.parametrize( + "message", + [ + {"role": "user", "content": "hi", "input_audio": {"data": "x"}}, + {"role": "user", "content": "hi", "input_video": {"data": "x"}}, + {"role": "user", "content": "hi", "audio_url": "u"}, + {"role": "user", "content": "hi", "video_url": "u"}, + ], +) +def test_openai_responses_rejects_ark_only_top_level_fields(message): + """带一个无关的 ``content`` 键就绕开了类型分流,Ark 专属块原样发给 + 不支持它的端点。""" + with pytest.raises(ValueError, match="不受 OpenAI Responses SDK 支持"): + normalize_responses_input(None, None, [message], provider="openai") + + # ── 回归:顶层 Ark 专属字段与带 role 的 Ark 专属块 ── diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index 20db835..75c36b3 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -3,6 +3,7 @@ import pytest from src.utils.security import ( + find_sensitive_option_paths, is_sensitive_option_key, redact_sensitive_text, validate_secret_free_options, @@ -445,14 +446,19 @@ def test_security_masked_rule_stays_linear_on_long_trailing_runs(): """ import time - text = "sk-abc***" + "deadbeef" * 500 + # 尾随一个掩码占位符 ``.`` 是必需的:正则尾部字符类含 ``.``,游程因此 + # 一直延伸到串尾;替换函数若用无锚点的 ``re.search(r"[A-Za-z0-9_-]+$")`` + # 找后缀,引擎会在每个起点重试 ``+$``,退化成 O(n²)。少了这个尾随字符 + # 时游程恰好在串尾结束,二次实现也能通过,测试形同虚设。 + for suffix in ("", "."): + text = "sk-abc***" + "deadbeef" * 500 + suffix - start = time.perf_counter() - redact_sensitive_text(text) - elapsed = time.perf_counter() - start + start = time.perf_counter() + redact_sensitive_text(text) + elapsed = time.perf_counter() - start - # 线性实现约 0.1ms;二次实现约 500ms。留足余量避免 CI 抖动误报。 - assert elapsed < 0.1, f"耗时 {elapsed * 1000:.1f}ms,疑似二次回溯" + # 线性实现约 0.3ms;二次实现约 90ms。留足余量避免 CI 抖动误报。 + assert elapsed < 0.05, f"耗时 {elapsed * 1000:.1f}ms,疑似二次回溯(suffix={suffix!r})" def test_security_masked_rule_keeps_adjacent_key_name_for_key_value_rule(): @@ -464,6 +470,46 @@ def test_security_masked_rule_keeps_adjacent_key_name_for_key_value_rule(): assert redacted == "[REDACTED]token=[REDACTED]" +def test_security_masked_rule_keeps_non_sensitive_key_name_before_equals(): + """掩码尾部游程后紧跟 ``=`` 时,它就是键值对的键名,必须保留。 + + 尾部字符类不含 ``=``,匹配正好停在分隔符前;若把游程整段吃掉, + ``=<值>`` 会失去锚点,后续键值规则再也匹配不到,值从脱敏变明文。 + 即使键名本身不是敏感键(``xxxtoken``),掩码前缀也说明它是凭证的 + 可见尾部,后面的值必须一并脱敏。 + """ + secret = "FakeSecretValue9876" + for key in ("xxxtoken", "oauth", "topsecret", "xyz"): + redacted = redact_sensitive_text(f"sk-abc***{key}={secret}") + assert secret not in redacted, key + assert redacted == f"[REDACTED]{key}=[REDACTED]", key + + +@pytest.mark.parametrize("literal", ["None", "NONE", "Null", "False", "Enabled", "TRUE"]) +def test_security_non_credential_literals_are_case_insensitive(literal): + """非凭证字面量枚举要大小写不敏感,否则 ``auth: None`` 会被判成凭证。 + + ``find_sensitive_option_paths`` 用「值是否被改写」判断值里有没有凭证, + 枚举漏掉大写变体就会让合法配置在边界被拒。 + """ + assert find_sensitive_option_paths({"prompt": f"auth: {literal}"}) == [] + + +@pytest.mark.parametrize("key", ["HTTPBearer", "HTTPSSecret", "HTTPToken"]) +def test_security_redacts_acronym_prefixed_camel_case_keys(key): + """``is_sensitive_option_key`` 的切词有「大写串接小写词」这条分支。 + + 文本侧的界断言此前只实现了前两条(非字母数字、小写接大写), + ``HTTPBearer`` 在配置边界判敏感、在文本里却明文输出——正是本文件 + 要消灭的那类不一致。 + """ + secret = "FakeSecretValue9876" + redacted = redact_sensitive_text(f"{key}: {secret}") + + assert secret not in redacted + assert is_sensitive_option_key(key) is True + + @pytest.mark.parametrize( "text", ["sk-abc***defghijkl: boom", "sess-abc***tail: unauthorized", "sk-abc***xyz: boom"], From 128f1fcd08741e3348e31402c1e0d368552970ba Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 17:29:36 +0800 Subject: [PATCH 17/31] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E4=BB=A3=E7=90=86=E5=87=BA=E5=8F=A3=E7=9A=84=E6=8C=87?= =?UTF-8?q?=E5=90=91=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 代理节点的注释把出口指向 `.native`,但该属性挂在 Facade 层 (`provider.resources.native`),代理节点本身没有——用户拿到的是节点时 这个指引不成立。改为明确指向 Facade 层。 Co-Authored-By: Claude Opus 5 (1M context) --- src/providers/resources.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/providers/resources.py b/src/providers/resources.py index 3f67f10..51a31e3 100644 --- a/src/providers/resources.py +++ b/src/providers/resources.py @@ -36,14 +36,15 @@ def decorator(method: _FacadeMethod) -> _FacadeMethod: class _NativeResourceProxy: """为 SDK 原生资源树增加请求扩展校验,同时保持结果对象原样返回。 - `.native` 仍然返回官方客户端本身;只有通过 Facade 动态访问的资源节点 - 使用此代理。这样既能兼容 SDK 新增资源,又不会让 `extra_headers`、 - `extra_query` 或 `extra_body` 绕过统一凭证边界。 - - 绕过说明:``.native`` 是文档化的出口,按设计返回完整原生树,不经过 - 凭证扫描与能力门禁。本代理内部的 ``_value`` 指向同一个对象,因此它 - 不是独立的绕过路径——想绕过门禁的用户用 ``.native`` 即可,无需依赖 - 实现细节。``_value`` 只是内部持有者,不属于公共 API。 + 只有通过 Facade 动态访问的资源节点使用此代理,这样既能兼容 SDK 新增 + 资源,又不会让 `extra_headers`、`extra_query` 或 `extra_body` 绕过统一 + 凭证边界。 + + 绕过说明:``provider.resources.native`` 是文档化的出口,按设计返回完整 + 原生客户端,不经过凭证扫描与能力门禁。注意它挂在 **Facade 层**,代理 + 节点本身没有 ``native`` 属性。本代理内部的 ``_value`` 指向同一个对象, + 因此它不是独立的绕过路径——想绕过门禁的用户用 ``resources.native`` + 即可,无需依赖实现细节。``_value`` 只是内部持有者,不属于公共 API。 """ __slots__ = ("_children", "_path", "_provider", "_value") @@ -53,7 +54,7 @@ def __dir__(self) -> list[str]: 两处来源都要过滤:``__slots__`` 里的自有槽位,以及 ``__getattr__`` 转发来的底层 SDK 节点属性(其中含 ``_client`` 这类可直达原始客户端的 - 通路)。出口是文档化的 ``.native``。 + 通路)。出口是 Facade 层的 ``provider.resources.native``。 """ # ``super().__dir__()`` 对 ``__slots__`` 类只给出 dunder 与槽位名, # 真实资源名要经 ``__getattr__`` 从底层节点取(并缓存进 ``_children``)。 From c300d0b6ffd65a49e431fe802dd2826995c19695 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 17:56:11 +0800 Subject: [PATCH 18/31] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=E6=81=92?= =?UTF-8?q?=E7=9C=9F=E6=96=AD=E8=A8=80=E3=80=81=E6=97=A0=E5=8C=BA=E5=88=86?= =?UTF-8?q?=E5=BA=A6=E7=94=A8=E4=BE=8B=E4=B8=8E=E6=8A=96=E5=8A=A8=E7=9A=84?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试有效性审查发现三处断言没有真实防护能力,均已用定向变异复验。 - 掩码尾部可见片段的断言用 startswith("[REDACTED]"),而掩码前缀在任何 实现下都会被替换成 [REDACTED],该断言恒真——尾部片段原样泄漏时也能通过。 改为全文相等,并用「让尾部字符类不吃尾部」的变异确认变红。 - 短纯字母凭证的 7 个用例里有 4 个取值 ≥12 字符,旧规则本就能命中,对 「短值」这个被测属性零区分度。改为真正短于 12 字符的值,断言改为检查 值本身被替换而不是「文本被改写过」(后者被键名之外的任何改写满足)。 - 掩码线性度用例用单次采样配 100ms 阈值,实测本机 p99.9 即到 100ms、 满载时更高,会间歇误报。改为放大规模到线性与二次相差三个数量级、 取多轮最小值;二次实现下连跑 4 次全部变红(原写法 5 次里有 1 次误判通过)。 - 为 _normalize_responses_native_item 的顶层字段守卫补直接调用测试:它对 当前唯一调用点是冗余的,没有测试能区分其存在,等于死代码。 测试总数 1037。 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +- tests/providers/test_sdk_capabilities.py | 16 +++++ tests/providers/test_security_boundaries.py | 72 +++++++++++++++------ 3 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e217c8b..b503969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,8 @@ ### 测试与文档 -- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1033。新增断言均经红绿验证(回退源码后对应测试变红),并替换了两处无区分度的旧断言:URL query 脱敏断言此前用的长值由另一条规则满足,Ark 响应夹具此前伪造了 SDK 不存在的 `output_text` 字段。 +- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1037。新增断言经红绿验证(回退对应源码后测试变红);其中一部分是**反向守卫**——回退源码不会变红,需要定向变异(例如「让守卫连 Ark 一起拒」)才能验证,这类断言守的是「不能过度收紧」,同样有区分度。 +- 修复三处测试有效性缺陷:掩码尾部可见片段的断言用 `startswith("[REDACTED]")`,而掩码前缀在任何实现下都会被替换成 `[REDACTED]`,该断言恒真——尾部片段原样泄漏时也能通过,改为全文相等;短纯字母凭证的 7 个用例里有 4 个取值 ≥12 字符,旧规则本就能命中、对被测属性零区分度,改为真正短于 12 字符的值并断言值本身被替换;掩码线性度用例用单次采样配 100ms 阈值,实测本机 p99.9 即到 100ms、满载时更高,会间歇误报,改为放大规模到线性与二次相差三个数量级、取多轮最小值。 - 为活动快照的 embedding 兼容性检测补充回归测试:快照记录的 embedding provider 或模型名与当前运行配置不一致时必须显式失败,避免用错向量空间后静默产出错误检索结果。 - 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置;新增 `.github/workflows/quality.yml`,在 PR 与 `main` 推送时执行格式化检查、lint、类型检查、安全扫描与完整测试,此前这些工具只在本地手动运行。 - 全仓库应用 `ruff format`(行宽 100、双引号、4 空格),此前未配置 formatter,`main.py`、`src/`、`scripts/`、`tests/` 中存在混用单引号、行尾空白和手工对齐等不一致;格式化只改表示不改语义,已用 AST 比对确认语法树等价,`AGENTS.md` 的质量检查与风格段落同步更新。 diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index d49e083..9cd3935 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -11,6 +11,7 @@ CompletionRequest, LargeLanguageModel, TextEmbeddingModel, + _normalize_responses_native_item, normalize_chat_messages, normalize_messages, normalize_responses_input, @@ -7371,6 +7372,21 @@ def test_openai_responses_rejects_ark_only_top_level_fields(message): normalize_responses_input(None, None, [message], provider="openai") +@pytest.mark.parametrize("field", ["input_audio", "input_video", "audio_url", "video_url"]) +def test_normalize_native_item_rejects_ark_only_fields_on_its_own(field): + """助手函数要能独立拦住 Ark 专属顶层字段。 + + ``normalize_responses_input`` 在进入原生 item 分支前已查过一遍,因此这层 + 守卫对当前唯一调用点是冗余的——但没有测试能区分它的存在,等于死代码。 + 这里直接调用助手,把它锚定住:若有人删掉这层自校验,助手被单独复用时 + 就会静默放行 Ark 专属字段。 + """ + with pytest.raises(ValueError, match="不受 OpenAI Responses SDK 支持"): + _normalize_responses_native_item( + {"role": "user", "content": "hi", field: "x"}, "loc", "openai" + ) + + # ── 回归:顶层 Ark 专属字段与带 role 的 Ark 专属块 ── diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index 75c36b3..196eef1 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -398,14 +398,15 @@ def test_connection_only_keys_are_deliberately_excluded_from_text_redaction(): @pytest.mark.parametrize( "value", [ - # 值必须「短且纯字母」——那是本用例要覆盖的属性(此前长度/字符构成 - # 约束会把这类值放过)。用明显合成的标记而非常见口令词:后者会触发 - # 仓库的密钥扫描告警,把提交历史染上无法消除的误报。 + # 值必须**真的短**(12 字符以下)且纯字母——那正是本用例要覆盖的属性。 + # 取值 ≥12 字符时旧规则本就能命中,回退源码后测试仍绿,等于没测。 + # 用明显合成的标记而非常见口令词:后者会触发仓库的密钥扫描告警, + # 把提交历史染上无法消除的误报。 "password: FakePwOnly", "api_key: FakeKeyOnly", - "token: FakeTokenOnly", - "secret: FakeSecretOnly", - "client_secret: FakeSecretOnly", + "token: FakeTokOnly", + "secret: FakeSecOnly", + "client_secret: FakeSecOnly", "passwd: FakePwOnly", "credential: FakeCredOnly", ], @@ -416,8 +417,13 @@ def test_security_redacts_short_alphabetic_credential_values(value): 此前对全部键名统一加「≥12 字符或含数字/符号」的形态约束,使 ``password: FakePwOnly`` 这类键值对整条漏检——那是比误判更严重的净漏检。 形态约束只该用于排除误判源,不该收窄真凭证的取值域。 + + 断言检查**值本身**被替换,而不是「文本被改写过」:后者会被键名之外的 + 任何一处改写满足,回退被测机制后仍能通过。 """ - assert redact_sensitive_text(value) != value + key, _, secret = value.partition(": ") + assert secret not in redact_sensitive_text(value), value + assert redact_sensitive_text(value) == f"{key}=[REDACTED]", value @pytest.mark.parametrize( @@ -437,6 +443,15 @@ def test_security_does_not_flag_ordinary_identifiers_ending_in_credential_words( # ── 回归:掩码规则的性能与尾部裁剪 ── +def _time_redaction(text: str) -> float: + """返回一次脱敏的墙钟耗时(秒)。""" + import time + + start = time.perf_counter() + redact_sensitive_text(text) + return time.perf_counter() - start + + def test_security_masked_rule_stays_linear_on_long_trailing_runs(): """掩码尾部不能引入二次回溯。 @@ -444,21 +459,31 @@ def test_security_masked_rule_stays_linear_on_long_trailing_runs(): 前缀、后面跟长 token 的错误体就能拖住进程。逐字符前瞻的写法在尾部无冒号 时退化成 O(n²)。 """ - import time # 尾随一个掩码占位符 ``.`` 是必需的:正则尾部字符类含 ``.``,游程因此 # 一直延伸到串尾;替换函数若用无锚点的 ``re.search(r"[A-Za-z0-9_-]+$")`` # 找后缀,引擎会在每个起点重试 ``+$``,退化成 O(n²)。少了这个尾随字符 # 时游程恰好在串尾结束,二次实现也能通过,测试形同虚设。 + # + # 两个维度都要拿捏: + # + # 1) 规模要足够大,让线性与二次的差距是**数量级**而非倍数。32000 字符 + # 时线性约 8ms、二次约 9.6 秒,相差三个数量级;阈值取 0.5 秒,向上 + # 离线性 60 倍、向下离二次 19 倍,两侧都不会被抖动穿透。此前用 4000 + # 字符配 20ms 阈值,实测二次实现 5 轮里有 1 轮跑进阈值而误判通过。 + # + # 2) 取多轮最小值而不是单次采样:单次墙钟受 GC 与调度影响,实测本机 + # p99.9 就到 100ms,单样本会撞上尾部事件误报。 + # + # 尾随的 ``.`` 是必需的一维:它不在正则尾部字符类里,游程因此够不到 + # ``$``,二次实现必然退化;无尾随字符时游程恰好延伸到串尾,二次实现 + # 也能通过,那一维只用来守住常见形态、不承担区分职责。 for suffix in ("", "."): - text = "sk-abc***" + "deadbeef" * 500 + suffix + text = "sk-abc***" + "deadbeef" * 4000 + suffix - start = time.perf_counter() - redact_sensitive_text(text) - elapsed = time.perf_counter() - start + best = min(_time_redaction(text) for _ in range(3)) - # 线性实现约 0.3ms;二次实现约 90ms。留足余量避免 CI 抖动误报。 - assert elapsed < 0.05, f"耗时 {elapsed * 1000:.1f}ms,疑似二次回溯(suffix={suffix!r})" + assert best < 0.5, f"最快一轮耗时 {best * 1000:.1f}ms,疑似二次回溯(suffix={suffix!r})" def test_security_masked_rule_keeps_adjacent_key_name_for_key_value_rule(): @@ -511,12 +536,21 @@ def test_security_redacts_acronym_prefixed_camel_case_keys(key): @pytest.mark.parametrize( - "text", - ["sk-abc***defghijkl: boom", "sess-abc***tail: unauthorized", "sk-abc***xyz: boom"], + ("text", "expected"), + [ + ("sk-abc***defghijkl: boom", "[REDACTED]: boom"), + ("sess-abc***tail: unauthorized", "[REDACTED]: unauthorized"), + ("sk-abc***xyz: boom", "[REDACTED]: boom"), + ], ) -def test_security_masked_rule_consumes_visible_trailing_fragment(text): - """尾部可见片段不是键名时,应随掩码一起吃掉,不能留在 ``[REDACTED]`` 之后。""" - assert redact_sensitive_text(text).startswith("[REDACTED]") +def test_security_masked_rule_consumes_visible_trailing_fragment(text, expected): + """尾部可见片段不是键名时,应随掩码一起吃掉,不能留在 ``[REDACTED]`` 之后。 + + 断言用全文相等而不是 ``startswith("[REDACTED]")``:掩码前缀总会被替换成 + ``[REDACTED]``,前缀断言在任何实现下都成立——包括尾部片段原样泄漏的实现, + 等于没测。 + """ + assert redact_sensitive_text(text) == expected def test_security_bearer_rule_redacts_alphabetic_token_adjacent_to_cjk(): From 82c4a28f1b60ddc354d4137e60f3cf788d69e308 Mon Sep 17 00:00:00 2001 From: Mison Date: Tue, 22 Sep 2026 18:05:07 +0800 Subject: [PATCH 19/31] =?UTF-8?q?test:=20=E7=94=A8=20FAKE=20=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=E6=9B=BF=E6=8D=A2=E5=AF=86=E9=92=A5=E5=BD=A2=E6=80=81?= =?UTF-8?q?=E7=9A=84=E6=B5=8B=E8=AF=95=E5=A4=B9=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitGuardian 对 PR 范围报 1 处未覆盖的密钥。核对后确认是测试夹具里的 合成值,但形态与真实密钥一致(顺序字母数字、`sk-live-` 前缀加十六进制), 会持续触发扫描器。统一替换为带 FAKE 标记的值,长度与字符构成保持 在被测规则命中的范围内,语义不变。 Co-Authored-By: Claude Opus 5 (1M context) From f2c42ed9afa1da1bd8b2fbea9203e9a5ad8cfc0e Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 00:09:13 +0800 Subject: [PATCH 20/31] =?UTF-8?q?fix:=20=E8=AE=A9=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E8=84=B1=E6=95=8F=E4=B8=8E=E9=85=8D=E7=BD=AE=E8=BE=B9=E7=95=8C?= =?UTF-8?q?=E5=85=B1=E7=94=A8=E5=90=8C=E4=B8=80=E5=A5=97=E9=94=AE=E5=90=8D?= =?UTF-8?q?=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 文本层此前用正则的字符断言去逼近 is_sensitive_option_key 的切词逻辑, 两者无法自然对齐,passwordHash、tokenCount、cookieJar 这类「凭证词 + 另一个词」的键出现双向不一致:真凭证在日志里明文输出,而键名相同的 合法配置值又在边界被误拒。 改为文本层捕获完整键名后调用同一个判定函数,一致性由构造保证。同时: - 短凭证键 ak/sk 的值补上非凭证字面量排除。这两个键在配置里常作开关 (sk: off),与裸关键词是同一类误判源,此前只给裸关键词加了约束。 - 脱敏替换不再改写分隔符。此前把 key: value 归一化成 key=value,会篡改 被脱敏文本的结构(JSON 片段因此变成非法 JSON);现在保留原文的 :/= 与引号。 - 基类 _require_provider_resource 的 kwargs 契约写明实现状态:只有 Volcengine 在 Facade 上做参数校验、因而需要 kwargs;其余 Provider 忽略它与契约一致,并注明凭证边界由代理层统一执行。 键名判定拆出 _is_credential_key(不含连接类容器键),使文本层能复用 凭证判定而不把 query/headers 这类连接键纳入。 验证:71568 条键值形态语料差分,14592 条由明文改为脱敏,零回归; 新增 24 项回归测试,测试总数 1061。 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 + src/providers/__base__/model_provider.py | 9 ++ src/providers/google.py | 7 +- src/providers/openai_compatible.py | 7 +- src/utils/security.py | 100 ++++++++++++++------ tests/providers/test_security_boundaries.py | 69 +++++++++++++- 6 files changed, 161 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b503969..bc716ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,10 @@ - 键名识别改用「非字母数字或 camelCase 边界」:既让 `dbPassword`、`myApiKey` 这类驼峰键脱敏,又不把 `topsecret`、`sessiontoken` 从词中间切开(配置边界按 camelCase 切词判它们非敏感,切开会导致合法配置被误拒)。排除误判源改用非凭证字面量枚举,而不是值的长度/字符构成——后者会把 `password: FakePwOnly` 这类短纯字母凭证一并放过。 - 修复掩码规则的二次回溯:尾部前瞻逐字符重复扫描剩余串,在尾部无冒号时退化成 O(n²)(4000 字符的掩码密钥前缀加长 token 耗时 1.4 秒)。该函数挂在每条日志的 formatter 上,一个回显错误体的网关响应即可拖住进程。 - 掩码规则不再吞掉键值对的键名后留下孤儿值:`sk-abc***xxxtoken=` 里的键名不是敏感键(按 camelCase 切词判非敏感),键值规则不会接手,而掩码又已把键名吃掉,值失去锚点后明文落进日志。现在掩码匹配把键值尾部一并纳入,只保留键名作为可读上下文。 +- 文本脱敏的键名判定改为调用 `is_sensitive_option_key`,与配置边界共用同一个函数:此前文本侧用正则的字符断言去逼近切词逻辑,`passwordHash`、`tokenCount`、`cookieJar` 这类「凭证词 + 另一个词」的键两个方向都不一致——真凭证在日志里明文输出,而键名相同的合法配置值又在边界被误拒。一致性现在由构造保证,不再依赖两条独立实现碰巧对齐。 +- 短凭证键(`ak`/`sk`)的值补上非凭证字面量排除:这两个键在配置里常作开关(`sk: off`),与裸关键词(`auth: none`)是同一类误判源,此前只给裸关键词加了约束。 +- 脱敏替换不再改写分隔符:此前把 `key: value` 归一化成 `key=value`,会篡改被脱敏文本的结构(JSON 片段因此变成非法 JSON)。现在保留原文的 `:`/`=` 与引号。 +- 基类 `_require_provider_resource` 的 `kwargs` 契约写明当前实现状态:只有 Volcengine 在 Facade 上做参数校验、因而需要 `kwargs`,其余 Provider 忽略它与契约一致;同时注明凭证边界不依赖本钩子,代理层在调用门禁前已统一校验。 - 修复替换函数里的二次回溯:尾部游程查找用无锚点的 `re.search(r"[A-Za-z0-9_-]+$")`,在每个起点重试 `+$`;尾部以 `.` 这类掩码占位符收尾时游程够不到 `$`,退化成 O(n²)(32010 字符 9.6 秒,4010 字符 86ms)。仓库原有线性测试的用例恰好无尾随字符,所以只跑 1.2ms 就通过、没能拦住。改为单遍扫描,并给该测试补上以掩码占位符收尾的形态。 - 非凭证字面量枚举改为大小写不敏感:`auth: None`、`token: Null`、`cookie: Enabled` 此前不被识别为状态字面量而判成凭证,`find_sensitive_option_paths` 据此把合法配置在边界误拒。 - 文本脱敏的界断言补齐第三条驼峰分支:`is_sensitive_option_key` 的切词含「大写串接小写词」,文本侧只实现了「非字母数字」与「小写接大写」两条,`HTTPBearer`/`HTTPSSecret` 这类首字母缩写键在配置边界判敏感、在日志里却明文输出。 diff --git a/src/providers/__base__/model_provider.py b/src/providers/__base__/model_provider.py index 8ef6979..95d9df1 100644 --- a/src/providers/__base__/model_provider.py +++ b/src/providers/__base__/model_provider.py @@ -1799,6 +1799,15 @@ def _require_provider_resource( ``kwargs`` 是即将交给原生方法的调用参数。动态资源路径不经过 Facade, 因此 Provider 若在 Facade 上做了参数校验,需要在这里对同一组参数 再校验一次,否则动态路径成为绕过参数约束的旁路。 + + 目前只有 ``VolcengineProvider`` 需要这么做(``_validate_native_response_kwargs`` + 校验 ``instructions`` 与 ``caching`` 的互斥)。其余 Provider 不在 Facade + 上做参数校验,因此忽略 ``kwargs`` 与契约一致,不是遗漏——新增 Facade + 参数校验时必须同步到这里,否则动态路径会成为旁路。 + + 凭证边界不依赖本钩子:``_NativeResourceProxy.__call__`` 在调用门禁 + 之前已对同一组参数执行 ``validate_secret_free_resource_*``,两条路径 + 的凭证约束一致。 """ return diff --git a/src/providers/google.py b/src/providers/google.py index 28bf70b..a49debb 100644 --- a/src/providers/google.py +++ b/src/providers/google.py @@ -400,7 +400,12 @@ def _require_resource(client: Any, name: str, label: str) -> Any: def _require_provider_resource( self, method_name: str, kwargs: Mapping[str, Any] | None = None ) -> None: - """把实验性资源的版本差异转换成明确的能力错误。""" + """把实验性资源的版本差异转换成明确的能力错误。 + + ``kwargs`` 未使用:本 Provider 的原生资源方法不做参数校验,动态路径 + 没有可绕过的参数约束。凭证边界由 ``_NativeResourceProxy.__call__`` + 在调用本钩子前统一执行。 + """ name = method_name.removeprefix("async_") resource_name: str | None = None # 显式 Facade 名(``create_interaction``)与动态资源树路径 diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index f1754e1..bcf4665 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -646,7 +646,12 @@ def _resource_capability_for_path(cls, method_name: str) -> str | None: def _require_provider_resource( self, method_name: str, kwargs: Mapping[str, Any] | None = None ) -> None: - """在原生资源方法真正触达 SDK 前校验渠道能力。""" + """在原生资源方法真正触达 SDK 前校验渠道能力。 + + ``kwargs`` 未使用:本 Provider 不在 Facade 上做参数校验,动态路径 + 因此没有可绕过的参数约束(兼容渠道的动态路径也不可达—— + ``OpenAICompatibleResources._delegate_native`` 为 ``False``)。 + """ capability = self._resource_capability_for_method(method_name) if capability is None: # 显式 Facade 名匹配不上时,再按动态资源树路径判断。 diff --git a/src/utils/security.py b/src/utils/security.py index 115f19e..bb8ccde 100644 --- a/src/utils/security.py +++ b/src/utils/security.py @@ -195,45 +195,72 @@ ) -def _key_value_pattern(keys: str, value_prefix: str = "") -> str: - """拼出 ``键名 = 值`` 的文本脱敏模式。 +# 键名候选:以凭证词开头、后接任意标识符字符。用「宽泛捕获 + 回调判定」 +# 而不是把每个完整键名写进正则:``passwordHash``/``tokenCount`` 这类 +# 「凭证词 + 另一个词」的键,在配置边界按切词判为敏感,正则里却列不全。 +# 候选只负责圈出可能的键名,是否敏感由 ``_is_credential_key`` 决定——与配置 +# 边界同一个函数,一致性由构造保证。 +# +# 前缀 ``_CREDENTIAL_KEY_HEAD`` 保持大小写敏感:``topsecret``/``oauth`` 里的 +# ``secret``/``auth`` 前面是小写字母接小写,不是驼峰边界,不会从词中间切开。 +_CREDENTIAL_KEY_CANDIDATE = r"(?i:(?:" + "|".join(_TEXT_CREDENTIAL_KEYS) + r")[A-Za-z0-9_\-]*)" +_CREDENTIAL_KEY_VALUE_RE = re.compile( + _CREDENTIAL_KEY_HEAD + # 尾随引号单独成组,替换时原样保留:``{"password": "x"}`` 里若把 ``"`` + # 一起吃掉,会输出 ``{"password: [REDACTED]}`` 破坏 JSON 结构。 + + r"(" + + _CREDENTIAL_KEY_CANDIDATE + + r")([\"']?)" + + r"""(\s*[:=]\s*|\s+(?=["']|""" + r"""(?=[A-Za-z0-9._~+/=-]{12,}(?:[\s,;}']|$))[A-Za-z0-9._~+/=-]*[0-9._~+/=-]))""" + + r"(" + + _ANY_VALUE_SHAPE + + r")" +) + +# 裸关键词:值可能只是状态字面量(``auth: none``/``password: none``), +# 需要形态约束。从 ``_TEXT_CREDENTIAL_KEYS`` 派生而不是手写,否则词表扩充 +# 时两处会失步——此前手写漏掉 ``password``/``credential`` 等五个,使 +# ``password: none`` 从「不脱敏」变成「脱敏」。 +_BARE_KEYWORD_KEYS = frozenset(key for key in _TEXT_CREDENTIAL_KEYS if re.fullmatch(r"[a-z]+", key)) + - ``keys`` 里每个分支都是完整键名(可含 ``[_-]?``),配合 - ``_CREDENTIAL_KEY_HEAD`` 的「非字母数字或 camelCase 边界」断言,既让 - ``dbPassword``/``myApiKey`` 命中,又不把 ``topsecret``/``sessiontoken`` - 从词中间切开——与 ``is_sensitive_option_key`` 的切词口径一致。 +def _redact_credential_pair(match: re.Match[str]) -> str: + """键名判定为凭证时替换其值,否则原样返回。 - ``value_prefix`` 插在取值之前,用于排除非凭证字面量。 + 键名交给 ``_is_credential_key`` 判定,与配置边界用的是同一个函数。 + 此前文本层用正则的字符断言去逼近切词逻辑,``passwordHash``/``tokenCount`` + 这类键在配置边界判敏感、在文本里却明文输出(而它们的合法配置值又会被 + 边界误拒),两个方向都不一致。 """ - return ( - _CREDENTIAL_KEY_HEAD + r"((?i:" + keys + r"))[\"']?" - r"""(\s*[:=]\s*|\s+(?=["']|""" - r"""(?=[A-Za-z0-9._~+/=-]{12,}(?:[\s,;}']|$))[A-Za-z0-9._~+/=-]*[0-9._~+/=-]))""" - + value_prefix - + _ANY_VALUE_SHAPE - ) + key, quote = match.group(1), match.group(2) + if not _is_credential_key(key): + return match.group(0) + value = match.group(4).strip("\"'") + # 裸关键词后接状态字面量时是普通文本(``auth: none``),不是凭证赋值。 + # 显式凭证键名(``password``/``api_key``…)的值一律脱敏。 + if normalize_option_key(key) in _BARE_KEYWORD_KEYS and re.fullmatch( + _NON_CREDENTIAL_VALUE, value, re.IGNORECASE + ): + return match.group(0) + return f"{key}{quote}{match.group(3)}[REDACTED]" -# 显式凭证键名(``password``、``api_key``、``client_secret``…)的值就是凭证, -# 任何取值都脱敏;裸关键词(``auth``/``cookie``/``secret``/``token``/``bearer``) -# 额外排除非凭证字面量。 -_KEY_VALUE_TEXT_RE = re.compile(_key_value_pattern(_QUALIFIED_CREDENTIAL_KEY_PATTERN)) -_BARE_KEYWORD_TEXT_RE = re.compile( - _key_value_pattern( - _BARE_CREDENTIAL_KEYWORD_PATTERN, - r"(?!(?i:" + _NON_CREDENTIAL_VALUE + r")(?![A-Za-z0-9._~+/=-]))", - ) -) _URL_USERINFO_RE = re.compile(r"(?i)(https?://)([^\s/@:]+):([^\s/@]+)@") _URL_QUERY_SECRET_RE = re.compile( r"(?i)([?&](?:" + _TEXT_CREDENTIAL_KEY_PATTERN + r"|ak|sk)=)[^&#\s]+" ) # ``ak``/``sk`` 是火山引擎凭证键名,``account_key`` 是 Azure 存储凭证键名。 # 这些键很短,必须用词边界约束,否则 ``task = value`` 里的 ``sk`` 会被误脱敏。 +# +# 值同样要排除状态字面量:``sk``/``ak`` 在配置里常作开关(``sk: off``), +# 与裸关键词(``auth: none``)是同一类误判源。此前只给裸关键词加了约束, +# 这两个短键没有,口径不一致。 _SHORT_CREDENTIAL_KEY_RE = re.compile( r"""(?i)(? str: # 的可见前缀,使后续的 ``sk-`` 锚点失效,尾部掩码片段就会残留。 redacted = _MASKED_CREDENTIAL_RE.sub(_redact_masked_credential, value) redacted = _BEARER_TEXT_RE.sub("Bearer [REDACTED]", redacted) - redacted = _KEY_VALUE_TEXT_RE.sub(r"\1=[REDACTED]", redacted) - redacted = _BARE_KEYWORD_TEXT_RE.sub(r"\1=[REDACTED]", redacted) + redacted = _CREDENTIAL_KEY_VALUE_RE.sub(_redact_credential_pair, redacted) redacted = _SHORT_CREDENTIAL_KEY_RE.sub(r"\1=[REDACTED]", redacted) redacted = _URL_USERINFO_RE.sub(r"\1[REDACTED]:[REDACTED]@", redacted) redacted = _URL_QUERY_SECRET_RE.sub(r"\1[REDACTED]", redacted) @@ -385,10 +411,15 @@ def normalize_option_key(key: Any) -> str: return "".join(char for char in str(key).lower() if char.isalnum()) -def is_sensitive_option_key(key: Any) -> bool: - """识别凭证键,避免把普通单词的 ``secret`` 子串误判为凭证。""" +def _is_credential_key(key: Any) -> bool: + """判定键名是否为凭证键(不含连接类容器键)。 + + 连接类容器键(``query``/``headers``/``params``)在配置边界同样被禁止, + 但原因是会覆盖请求边界、本身不是凭证。文本脱敏只关心凭证,把它们一并 + 纳入会让普通配置值(``query: foo``)被误判为凭证。 + """ normalized = normalize_option_key(key) - if normalized in FORBIDDEN_OPTION_CONTAINERS or normalized in SENSITIVE_OPTION_KEYS: + if normalized in SENSITIVE_OPTION_KEYS: return True # 只对明确的词边界进行组合键识别;例如 ``secretary`` 不会命中, @@ -438,7 +469,14 @@ def is_sensitive_option_key(key: Any) -> bool: ): return True compact = "".join(words) - return compact in SENSITIVE_OPTION_KEYS or compact in FORBIDDEN_OPTION_CONTAINERS + return compact in SENSITIVE_OPTION_KEYS + + +def is_sensitive_option_key(key: Any) -> bool: + """识别凭证键与连接覆盖键,避免把普通单词的 ``secret`` 子串误判为凭证。""" + if normalize_option_key(key) in FORBIDDEN_OPTION_CONTAINERS: + return True + return _is_credential_key(key) def _url_credential_paths(value: Any, path: str) -> list[str]: diff --git a/tests/providers/test_security_boundaries.py b/tests/providers/test_security_boundaries.py index 196eef1..10f5a60 100644 --- a/tests/providers/test_security_boundaries.py +++ b/tests/providers/test_security_boundaries.py @@ -422,8 +422,73 @@ def test_security_redacts_short_alphabetic_credential_values(value): 任何一处改写满足,回退被测机制后仍能通过。 """ key, _, secret = value.partition(": ") - assert secret not in redact_sensitive_text(value), value - assert redact_sensitive_text(value) == f"{key}=[REDACTED]", value + redacted = redact_sensitive_text(value) + assert secret not in redacted, value + # 只要求键名与值被正确处理,不钉死分隔符:实现保留原文的 ``:``/``=``, + # 不会把分隔符改写成 ``=``(那会篡改被脱敏文本的结构)。 + assert redacted == f"{key}: [REDACTED]", value + + +@pytest.mark.parametrize( + "key", + [ + "passwordHash", + "passwordSalt", + "tokenCount", + "tokenExpiry", + "cookieJar", + "cookieName", + "apikeyValue", + "bearerFormat", + "accessTokenHash", + "authTokenCount", + "password_hash", + "token_count", + "cookie_jar", + ], +) +def test_security_text_and_boundary_agree_on_compound_credential_keys(key): + """文本脱敏与配置边界必须对同一个键给出相同判定。 + + ``is_sensitive_option_key`` 按 camelCase/分隔符切词,``passwordHash`` 切出 + ``password``+``hash`` 判为敏感;文本侧此前只用正则的字符断言逼近这套切词, + 这些「凭证词 + 另一个词」的键因此出现双向不一致:真凭证在日志里明文输出, + 而键名相同的合法配置值又在边界被误拒。现在文本侧也调用同一个判定函数。 + """ + secret = "FakeSecretValue9876" + redacted = redact_sensitive_text(f"{key}: {secret}") + + assert is_sensitive_option_key(key) is True, key + assert secret not in redacted, key + assert redacted == f"{key}: [REDACTED]", key + + +@pytest.mark.parametrize( + "key", ["topsecret", "sessiontoken", "oauth", "passwordless", "secretive", "cookiecutter"] +) +def test_security_text_and_boundary_agree_on_non_credential_lookalikes(key): + """切词后只有一个普通词的键,两侧都要放行。 + + 这些键含凭证词的子串,但按切词不是凭证——文本侧若从词中间切开,会让 + ``find_sensitive_option_paths`` 据此拒绝合法配置。 + """ + value = "plainvalue" + redacted = redact_sensitive_text(f"{key}: {value}") + + assert is_sensitive_option_key(key) is False, key + assert redacted == f"{key}: {value}", key + + +@pytest.mark.parametrize("literal", ["none", "off", "enabled", "false", "0"]) +def test_security_short_credential_keys_exclude_state_literals(literal): + """``sk``/``ak`` 在配置里常作开关,状态字面量不该被当成凭证值。 + + 这两个键与裸关键词(``auth``/``token``)是同一类误判源,此前只给裸关键词 + 加了值约束,短键没有,口径不一致。 + """ + text = f"sk: {literal}" + + assert redact_sensitive_text(text) == text @pytest.mark.parametrize( From b51f5a49e9bf4e19c030e2bdf69d3c04f9452fa3 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 07:48:17 +0800 Subject: [PATCH 21/31] =?UTF-8?q?chore:=20=E6=B8=85=E7=90=86=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E9=81=97=E7=95=99=E7=9A=84=E6=AD=BB=E5=B8=B8=E9=87=8F?= =?UTF-8?q?=E5=B9=B6=E6=94=B6=E7=B4=A7=E5=BC=82=E5=B8=B8=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 _BARE_CREDENTIAL_KEYWORD_PATTERN 与 _QUALIFIED_CREDENTIAL_KEY_PATTERN: 上一轮把文本层改成「宽泛捕获 + 回调判定」后,这两个模式串不再被引用, 由 _BARE_KEYWORD_KEYS 取代。 - 两处裸 pytest.raises 补上 match:只查异常类型的话,被测代码因别的原因 抛同类异常也会让测试通过。已用「抛出无关 ValueError」的变异确认带 match 的断言会变红。 Co-Authored-By: Claude Opus 5 (1M context) --- src/utils/security.py | 9 +-------- tests/providers/test_sdk_capabilities.py | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/utils/security.py b/src/utils/security.py index bb8ccde..721f6e4 100644 --- a/src/utils/security.py +++ b/src/utils/security.py @@ -187,13 +187,6 @@ _ANY_VALUE_SHAPE = r"""(?:"[^"]*"|'[^']*'|[^\s,;}']+)""" -_BARE_CREDENTIAL_KEYWORD_PATTERN = "|".join( - key for key in _TEXT_CREDENTIAL_KEYS if re.fullmatch(r"[a-z]+", key) -) -_QUALIFIED_CREDENTIAL_KEY_PATTERN = "|".join( - key for key in _TEXT_CREDENTIAL_KEYS if not re.fullmatch(r"[a-z]+", key) -) - # 键名候选:以凭证词开头、后接任意标识符字符。用「宽泛捕获 + 回调判定」 # 而不是把每个完整键名写进正则:``passwordHash``/``tokenCount`` 这类 @@ -212,7 +205,7 @@ + _CREDENTIAL_KEY_CANDIDATE + r")([\"']?)" + r"""(\s*[:=]\s*|\s+(?=["']|""" - r"""(?=[A-Za-z0-9._~+/=-]{12,}(?:[\s,;}']|$))[A-Za-z0-9._~+/=-]*[0-9._~+/=-]))""" + + r"""(?=[A-Za-z0-9._~+/=-]{12,}(?:[\s,;}']|$))[A-Za-z0-9._~+/=-]*[0-9._~+/=-]))""" + r"(" + _ANY_VALUE_SHAPE + r")" diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index 9cd3935..8ab4343 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -6371,8 +6371,12 @@ def test_ark_native_response_kwargs_allow_non_conflicting_combinations(kwargs): ) def test_openai_responses_rejects_ark_only_blocks_as_top_level_items(item): """同一个 Ark 专属块写成 content 会被拒、写成顶层 item 却直通请求体, - 等于绕过了 variant 守卫。两种形态必须一致拒绝。""" - with pytest.raises(ValueError): + 等于绕过了 variant 守卫。两种形态必须一致拒绝。 + + 断言带上 ``match``:只查异常类型的话,被测代码因别的原因抛 ``ValueError`` + 也会让测试通过。 + """ + with pytest.raises(ValueError, match="不受|缺少|必须"): normalize_responses_input(None, None, [dict(item)], provider="openai") @@ -6934,8 +6938,12 @@ def test_openai_responses_rejects_ark_only_blocks_with_or_without_content_key(me ], ) def test_ark_responses_validates_native_item_required_fields(message): - """顶层 item 形态也要做必填字段与取值范围校验,不能只查类型。""" - with pytest.raises(ValueError): + """顶层 item 形态也要做必填字段与取值范围校验,不能只查类型。 + + 断言带上 ``match``,把「必填字段/取值校验报的错」与「其它 ValueError」 + 区分开。 + """ + with pytest.raises(ValueError, match="缺少|必须|不受"): normalize_responses_input(None, None, [message], provider="ark") From b7c36159ca4d7495982b360859776f0a347cdaef Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 07:56:52 +0800 Subject: [PATCH 22/31] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20CHANGELOG=20?= =?UTF-8?q?=E7=9A=84=E6=B5=8B=E8=AF=95=E6=80=BB=E6=95=B0=E5=88=B0=201061?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本轮补充回归测试后总数由 1037 增至 1061,CHANGELOG 的数字未同步。 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc716ae..4ed79e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ ### 测试与文档 -- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1037。新增断言经红绿验证(回退对应源码后测试变红);其中一部分是**反向守卫**——回退源码不会变红,需要定向变异(例如「让守卫连 Ark 一起拒」)才能验证,这类断言守的是「不能过度收紧」,同样有区分度。 +- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1061。新增断言经红绿验证(回退对应源码后测试变红);其中一部分是**反向守卫**——回退源码不会变红,需要定向变异(例如「让守卫连 Ark 一起拒」)才能验证,这类断言守的是「不能过度收紧」,同样有区分度。 - 修复三处测试有效性缺陷:掩码尾部可见片段的断言用 `startswith("[REDACTED]")`,而掩码前缀在任何实现下都会被替换成 `[REDACTED]`,该断言恒真——尾部片段原样泄漏时也能通过,改为全文相等;短纯字母凭证的 7 个用例里有 4 个取值 ≥12 字符,旧规则本就能命中、对被测属性零区分度,改为真正短于 12 字符的值并断言值本身被替换;掩码线性度用例用单次采样配 100ms 阈值,实测本机 p99.9 即到 100ms、满载时更高,会间歇误报,改为放大规模到线性与二次相差三个数量级、取多轮最小值。 - 为活动快照的 embedding 兼容性检测补充回归测试:快照记录的 embedding provider 或模型名与当前运行配置不一致时必须显式失败,避免用错向量空间后静默产出错误检索结果。 - 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置;新增 `.github/workflows/quality.yml`,在 PR 与 `main` 推送时执行格式化检查、lint、类型检查、安全扫描与完整测试,此前这些工具只在本地手动运行。 From c9bb58a6ab9c895f62e99d54f6503915ebb15609 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 12:24:04 +0800 Subject: [PATCH 23/31] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E8=A7=84?= =?UTF-8?q?=E5=88=92=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0=E7=9A=84=E8=AE=B8?= =?UTF-8?q?=E5=8F=AF=E3=80=81=E8=84=B1=E6=95=8F=E4=B8=8E=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对最近一个月的 20 份规划类文本做全维度审查后,修复其中经独立复现确认的问题。 - 恢复 `src/etl/splitters/recursive_text_splitter.py` 的 Dify 移植声明头。 该头在引入 tiktoken 与层级分片的重写中被误删,此后 `sync dify rag hierarchical retrieval` 又向这个已无头的文件移植了父子分片逻辑, 使文件含 Dify 衍生代码却没有版权与许可证声明,与 AGENTS.md 的合规 要求及 Apache 2.0 第 4(b) 条不符。该删改在 main 上,非本 PR 引入。 - Responses 错误出口补齐脱敏:`_raise_for_response_error` 此前把服务端 消息直接拼进异常,而同一适配器的 Chat Completions 出口已经过 `redact_sensitive_text`。服务端错误常回显请求头或 URL,异常文本会进 日志与终端,是凭证最容易泄漏的出口,现两个出口行为一致。 - 修正 Excel 记录落盘位置:`ExcelLogger` 硬编码相对路径 `data/logs`, 而文本日志用经 ROOT_DIR 解析的 `settings.log_path`。相对路径基于 CWD, 从仓库外运行会分叉,现默认复用 `settings.log_path`。 - `capability_report` 与 `protocol_status` 对齐角色语义:前者在 embedding/rerank 角色下仍报告 `server_verified_protocols` 并接纳运行期 登记,后者对同一输入显式报错。协议只适用于 LLM 角色,两个诊断入口 现给出相同结论。 - 补充回归测试:重排乱序路径(既有替身恒返回 `[0]`,重排与降序排序 从未被执行)、`_validate_rerank_output` 的布尔混入与越界 index、 Responses 错误出口脱敏、`top_n` 非法值。修复 `test_rerank_accepts_top_n_larger_than_document_count` 的恒真调用。 - 文档校正:`REFACTORING_PLAN.md` 标注为 v1.2.0 历史归档并修正三处与 现状不符的表述;`README.md` 移除已退役模型举例、修正 `/config` 可调项 描述、补全 `src/` 目录树、把许可说明指向实际存在的许可证文件; `getting-started.md` 移除不存在的 `exit`;`developer-guide.md` 补全目录树。 测试数 1061 -> 1088。 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 12 ++- README.md | 16 ++-- docs/SUMMARY.md | 2 +- docs/developer_docs/REFACTORING_PLAN.md | 23 +++-- docs/developer_docs/developer-guide.md | 4 +- docs/user_guide/getting-started.md | 2 +- src/etl/splitters/recursive_text_splitter.py | 4 + src/providers/factory.py | 8 +- src/providers/openai_compatible.py | 7 +- src/retrieval_test/excel_logger.py | 9 +- tests/providers/test_failure_contracts.py | 39 +++++++++ tests/providers/test_rerank_contracts.py | 13 ++- .../retrieval/test_parent_child_retrieval.py | 84 +++++++++++++++++++ 13 files changed, 201 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ed79e2..7a093f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ ### 测试与文档 -- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1061。新增断言经红绿验证(回退对应源码后测试变红);其中一部分是**反向守卫**——回退源码不会变红,需要定向变异(例如「让守卫连 Ark 一起拒」)才能验证,这类断言守的是「不能过度收紧」,同样有区分度。 +- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1088。新增断言经红绿验证(回退对应源码后测试变红);其中一部分是**反向守卫**——回退源码不会变红,需要定向变异(例如「让守卫连 Ark 一起拒」)才能验证,这类断言守的是「不能过度收紧」,同样有区分度。 - 修复三处测试有效性缺陷:掩码尾部可见片段的断言用 `startswith("[REDACTED]")`,而掩码前缀在任何实现下都会被替换成 `[REDACTED]`,该断言恒真——尾部片段原样泄漏时也能通过,改为全文相等;短纯字母凭证的 7 个用例里有 4 个取值 ≥12 字符,旧规则本就能命中、对被测属性零区分度,改为真正短于 12 字符的值并断言值本身被替换;掩码线性度用例用单次采样配 100ms 阈值,实测本机 p99.9 即到 100ms、满载时更高,会间歇误报,改为放大规模到线性与二次相差三个数量级、取多轮最小值。 - 为活动快照的 embedding 兼容性检测补充回归测试:快照记录的 embedding provider 或模型名与当前运行配置不一致时必须显式失败,避免用错向量空间后静默产出错误检索结果。 - 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置;新增 `.github/workflows/quality.yml`,在 PR 与 `main` 推送时执行格式化检查、lint、类型检查、安全扫描与完整测试,此前这些工具只在本地手动运行。 @@ -68,6 +68,16 @@ - 需要重建知识库快照:`gemini-embedding-2` 与 `text-embedding-004` 的向量空间不兼容,旧 FAISS 索引不能复用。火山的 `doubao-embedding-text-240715` 已于 2025-12-26 停止新购(EOM),且已不在方舟「向量化能力」模型列表中;官方下线公告给出的迁移目标是 `doubao-embedding-vision-251215`,该模型同时接受纯文本输入,但它按 `/api/v3/embeddings/multimodal` 提供,与本项目使用的文本 `embeddings.create` 路径不同,迁移前需实测。embedding 模型只有 EOM 阶段、不涉及 EOS,存量接入点不受影响,因此示例配置暂未改动该值。 - 修复 Anthropic 采样字段弃用识别:家族名不再限定 `opus`/`sonnet`/`haiku`,覆盖 5 代新增的 `claude-fable-5`、`claude-mythos-5` 等命名;此前这些模型会被透传 `temperature`/`top_p`/`top_k`,而 Python SDK v1.0+ 已移除这些参数,请求会直接失败。 +### 审查修复 + +- 恢复 `src/etl/splitters/recursive_text_splitter.py` 的 Dify 移植声明头。该文件在引入 tiktoken 与层级分片的重写中被误删头部,此后 `sync dify rag hierarchical retrieval` 又向这个已无头的文件移植了父子分片逻辑,导致文件含 Dify 衍生代码却没有版权与许可证声明,与 `AGENTS.md` 的合规要求及 Apache 2.0 第 4(b) 条不符。 +- Responses 错误出口补齐脱敏:`_raise_for_response_error` 此前把服务端消息直接拼进异常,而同一适配器的 Chat Completions 出口已经过 `redact_sensitive_text`。服务端错误常回显请求头或 URL,异常文本会进日志与终端,是凭证最容易泄漏的出口,现两个出口行为一致。 +- 修复召回测试 Excel 记录的落盘位置:`ExcelLogger` 硬编码相对路径 `data/logs`,而文本日志使用经 `ROOT_DIR` 解析的 `settings.log_path`。相对路径基于 CWD,从仓库外运行程序时两者会分叉,Excel 记录落到当前工作目录。现默认复用 `settings.log_path`。 +- `capability_report` 与 `protocol_status` 对齐角色语义:前者在 embedding/rerank 角色下仍报告 `server_verified_protocols` 并接纳运行期登记,而后者对同一输入显式报错。协议只适用于 LLM 角色,两个诊断入口现给出相同结论。 +- 补充重排乱序路径与重排契约的回归测试:既有替身恒返回 `[0]`(恒等置换),`_rerank_if_needed` 的重排与按分数降序排序从未被执行,`_validate_rerank_output` 的布尔混入与越界 index 分支也无覆盖。 +- 修复无断言测试:`test_rerank_accepts_top_n_larger_than_document_count` 只调用 `_validate_inputs` 而无断言,即使行为变更也不会失败;现补断言并新增 `top_n` 非法值(含 `bool` 混入)的参数化用例。 +- 文档校正:`REFACTORING_PLAN.md` 标注为 v1.2.0 历史归档并修正三处与现状不符的表述(`PipelineManager` 实为 `Pipeline`、依赖应声明在 `pyproject.toml`、当时配置格式为 `config.ini`);`README.md` 移除已退役的模型举例、修正 `/config` 可调项描述、补全 `src/` 目录树、把许可说明指向实际存在的许可证文件;`getting-started.md` 移除不存在的 `exit` 退出方式;`developer-guide.md` 补全目录树。 + ## [1.3.0] - 2026-03-20 ### 运行与配置 diff --git a/README.md b/README.md index 544c07c..abdd0d4 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ ## ✨ 核心能力 - **🏗️ 异步 RAG 架构**: 基于 `asyncio` 构建的非阻塞检索流水线,支持高并发处理与流式响应输出。 -- **🔌 模块化扩展 (`ProviderFactory`)**: 无缝集成 Google Gemini (采用最新 `google-genai` SDK)、OpenAI GPT-4o、Anthropic Claude 3.5、DeepSeek 以及国产闭源/开源模型(豆包、通义千问等)。 +- **🔌 模块化扩展 (`ProviderFactory`)**: 无缝集成 Google Gemini (采用最新 `google-genai` SDK)、OpenAI、Anthropic Claude、DeepSeek 以及国产闭源/开源模型(豆包、通义千问等)。示例配置见 [`config.toml.example`](./config.toml.example)。 - **🚀 混合检索策略 (Hybrid Search)**: 深度复现 Dify 混合检索逻辑,支持语义向量检索、全文检索(BM25)及其加权分值融合。 - **🎯 语义精排 (Rerank)**: 支持集成 Jina AI、SiliconFlow 等 Rerank 模型,对海量召回结果进行二次精排,解决 RAG 系统中的“召回精度不足”问题。 - **🧱 本地可复现默认链路**: 默认使用 `local-hash` 嵌入模型,本地无需额外 Embedding API 即可完成知识库构建、召回测试和聊天验证。 -- **⚙️ 交互式配置控制**: 通过 `/config` 命令在运行时动态调整全局参数,包括检索 Top-K、权重配比及重试策略。 +- **⚙️ 交互式配置控制**: 通过 `/config` 命令在运行时动态调整全局参数,包括检索模式、Top-K、Rerank 开关、混合权重、融合策略与候选倍率,并可切换当前 LLM / Rerank 模型。 - **🧪 架构级验证工具**: 内置 `AGENTS.md` 指导原则与全面的 `pytest` 测试套件,确保每一行核心逻辑的可重复性验证。 说明: @@ -47,11 +47,15 @@ ├── src/ │ ├── chat/ # 会话控制中心:响应流管理与 RAG 循环逻辑 │ ├── providers/ # 供应商适配层:标准化 SDK 调用与异常隔离 -│ ├── retrieval/ # 检索引擎:向量存储与混合搜索算法 +│ ├── retrieval/ # 检索引擎:向量存储、快照与混合搜索算法 +│ ├── retrieval_test/ # 召回测试入口与 Excel 记录 │ ├── etl/ # 数据管道:文档结构化、清洗与分块向量化 +│ ├── models/ # 领域模型:文档与元数据 +│ ├── runtime/ # 运行期契约:配置对象与快照清单 +│ ├── services/ # 应用服务层:知识库构建、检索、聊天 │ ├── ui/ # 交互界面:动态配置菜单与 Rich 渲染 │ └── utils/ # 基础设施:强类型配置 (Pydantic) 与日志系统 -├── scripts/ # 工具脚本:大规模知识库离线构建 +├── scripts/ # 工具脚本:知识库构建、发布打包与说明提取 ├── tests/ # 验证矩阵:覆盖核心组件的单元测试 └── data/ # 持久化层:向量索引文件与审计日志 ``` @@ -150,7 +154,7 @@ uv run python scripts/build_binary_release.py --target macos-x64 --validate - [用户指南](./docs/user_guide/introduction.md) - [核心概念](./docs/user_guide/core-concepts.md) - [开发者指南](./docs/developer_docs/developer-guide.md) -- [重构路线图](./docs/developer_docs/REFACTORING_PLAN.md) +- [重构路线图(v1.2.0 历史归档)](./docs/developer_docs/REFACTORING_PLAN.md) --- @@ -161,7 +165,7 @@ uv run python scripts/build_binary_release.py --target macos-x64 --validate - **Dify 移植板块**: 本项目在 `src/etl/` 和 `src/retrieval/` 等目录中使用了 Dify 核心代码。这些部分遵循 [Dify Modified Apache License 2.0](DIFY_LICENSE)。禁止通过此部分代码构建多租户商业服务,且必须保留原始作者版权。 - **项目框架层**: 本项目自身的工程化架构、Provider 适配层及测试链路采用 [MIT 许可证](LICENSE)。 -详细移植列表与版权说明请参阅 [`AGENTS.md`](AGENTS.md)。 +许可证全文见 [`DIFY_LICENSE`](DIFY_LICENSE) 与 [`licenses/APACHE-2.0.txt`](licenses/APACHE-2.0.txt);移植与合规约定见 [`AGENTS.md`](AGENTS.md)。 ---
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index c1db208..c5a8ce9 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -12,4 +12,4 @@ ## 开发者文档 * [开发者指南](./developer_docs/developer-guide.md) -* [重构与演进路线图](./developer_docs/REFACTORING_PLAN.md) \ No newline at end of file +* [重构与演进路线图(历史归档)](./developer_docs/REFACTORING_PLAN.md) \ No newline at end of file diff --git a/docs/developer_docs/REFACTORING_PLAN.md b/docs/developer_docs/REFACTORING_PLAN.md index 3e835fe..21458b1 100644 --- a/docs/developer_docs/REFACTORING_PLAN.md +++ b/docs/developer_docs/REFACTORING_PLAN.md @@ -1,6 +1,18 @@ # PyRAG-Kit 重构与演进路线图 -> **注意**: 本文档记录了项目从早期版本 (v1.0.0) 演进至现代化架构 (v1.2.0) 的核心重构计划与思考过程。 +> **历史归档(2025-07-03 定稿,不再维护)**:本文档记录 v1.0.0 → v1.2.0 的重构蓝图与 +> 已完成的实施过程,不含任何未结任务。当前架构与用法请见 [开发者指南](./developer-guide.md) +> 与 [CHANGELOG](../../CHANGELOG.md)(当前版本 1.4.0)。 +> +> 阅读时注意三处已过时的表述: +> 1. 文中的 `PipelineManager` 已重命名为 `Pipeline`(`src/etl/pipeline.py`), +> 调用方是 `src/services/knowledge_build_service.py`,而非 `main.py`。 +> 2. 依赖已在 `pyproject.toml` 声明、由 `uv` 管理;`requirements.txt` 是 +> `uv export --locked` 的生成产物,不应手工编辑。 +> 3. 配置格式当时为 `config.ini`,现已迁移到 `config.toml`。 +> +> `src/etl/`、`src/retrieval/` 为 Dify 衍生代码,遵循 +> [DIFY_LICENSE](../../DIFY_LICENSE) 并保留上游版权声明。 ## 1. 愿景与目标 @@ -64,8 +76,9 @@ graph TD * **目标**: 统一并强化配置管理,为后续的工厂模式提供更可靠的配置源。 * **核心任务**: - 1. **引入 Pydantic**: 在`requirements.txt`中添加`pydantic`。 - 2. **创建配置模型**: 在`src/utils/config.py`中,使用Pydantic模型来定义强类型的配置结构,替代现有的 `config.toml` 分散读取方式。 + 1. **引入 Pydantic**: 声明 `pydantic` 依赖(当时记录为编辑 `requirements.txt`; + 该文件现为 `uv export` 生成产物,依赖应改在 `pyproject.toml` 中声明)。 + 2. **创建配置模型**: 在`src/utils/config.py`中,使用Pydantic模型来定义强类型的配置结构,替代当时分散读取的 `config.ini`。 3. **提供全局配置实例**: 提供一个全局可访问的、经过验证的配置对象。 #### 第二阶段:向量存储系统解耦 (实施重构支柱二) @@ -84,8 +97,8 @@ graph TD 1. **创建ETL模块**: 在`src/`下创建`etl/`目录,用于存放所有数据处理逻辑。 2. **定义处理器基类**: 在`etl/`下创建`extractors`, `cleaners`, `splitters`子目录,并为每种处理器定义抽象基类。 3. **实现具体处理器**: 提供针对Markdown的抽取器、基础的文本清洗器和递归文本分割器的具体实现。 - 4. **创建流水线管理器**: 在`etl/pipeline.py`中创建一个`PipelineManager`,它可以根据文件类型和配置,动态地组合这些处理器来处理文档。 - 5. **整合**: 改造`main.py`中的向量化选项,使其调用`PipelineManager`来执行处理。 + 4. **创建流水线管理器**: 在`etl/pipeline.py`中创建一个流水线管理器(当时记作 `PipelineManager`,实际落地为 `Pipeline`),它可以根据文件类型和配置,动态地组合这些处理器来处理文档。 + 5. **整合**: 由 `main.py` 经服务层调用该流水线执行处理(实际路径:`main.py` → `src/services/knowledge_build_service.py` → `Pipeline.from_file_path`)。 #### 第四阶段:提升健壮性与开发者体验 (DX) diff --git a/docs/developer_docs/developer-guide.md b/docs/developer_docs/developer-guide.md index c443143..a5d44c8 100644 --- a/docs/developer_docs/developer-guide.md +++ b/docs/developer_docs/developer-guide.md @@ -12,7 +12,9 @@ PyRAG-Kit 采用了清晰、模块化的项目结构,旨在实现高内聚、 │ ├── chat/ # 聊天核心逻辑 (core.py) │ ├── etl/ # 数据处理流水线 (提取、清洗、分割) │ ├── providers/ # 所有模型提供商的实现 -│ ├── retrieval/ # 检索逻辑 (retriever.py, vdb/) +│ ├── models/ # 领域模型 (document.py) +│ ├── retrieval/ # 检索逻辑 (retriever.py, vdb/, snapshot_repository.py) +│ ├── retrieval_test/ # 召回测试入口与 Excel 记录 │ ├── runtime/ # 运行期配置对象与快照契约 │ ├── services/ # 应用服务层 (构建、检索、聊天) │ ├── ui/ # 用户界面 (config_menu.py, display_utils.py) diff --git a/docs/user_guide/getting-started.md b/docs/user_guide/getting-started.md index 926a55c..67a118d 100644 --- a/docs/user_guide/getting-started.md +++ b/docs/user_guide/getting-started.md @@ -92,4 +92,4 @@ uv run python -m scripts.embed_knowledge_base --mode standard **常用命令:** * 在聊天界面输入 `/config` 可以随时打开动态配置菜单,切换模型或调整检索参数。 -* 输入 `/quit` 或 `exit` 可以安全地退出程序。 +* 输入 `/quit`(或按 `Ctrl+C` / `Ctrl+D`)可以安全地退出程序。 diff --git a/src/etl/splitters/recursive_text_splitter.py b/src/etl/splitters/recursive_text_splitter.py index 48b6de2..4786e90 100644 --- a/src/etl/splitters/recursive_text_splitter.py +++ b/src/etl/splitters/recursive_text_splitter.py @@ -1,3 +1,7 @@ +# 本文件包含部分从 Dify 项目移植的代码。 +# 原始来源: https://github.com/langgenius/dify +# 遵循修改后的 Apache License 2.0 许可证。详情请参阅项目根目录下的 DIFY_LICENSE 文件。 + import re import uuid from collections.abc import Callable diff --git a/src/providers/factory.py b/src/providers/factory.py index 7ee7316..e66f03a 100644 --- a/src/providers/factory.py +++ b/src/providers/factory.py @@ -398,11 +398,15 @@ def capability_report( # `protocols` describes LLM wire protocols. Embedding and Rerank use # their own endpoint contracts and must not inherit chat protocol names. protocols = sorted(info.get("protocols", set())) if normalized_role == "llm" else [] - verified = set(info.get("server_verified_protocols", set())) + # 与 protocol_status 对齐:非 llm 角色的协议不适用,其 server_verified + # 登记既不能被报告,也不能被 _runtime_verified_protocols 接纳。 + verified = ( + set(info.get("server_verified_protocols", set())) if normalized_role == "llm" else set() + ) verified.update( cls._runtime_verified_protocols( options, - set(info.get("protocols", set())), + set(info.get("protocols", set())) if normalized_role == "llm" else set(), resolved, ) ) diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index bcf4665..2929ca4 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -2406,7 +2406,10 @@ def _error_message(cls, error: Any, default: str) -> str: def _raise_for_response_error(cls, response: Any) -> None: error = cls._field(response, "error") if error: - raise RuntimeError(f"Responses API 返回错误: {cls._error_message(error, '未知错误。')}") + # 与 _raise_for_chat_response_error 对齐:服务端消息常回显请求头或 + # URL,异常文本会进日志与终端,是本项目凭证最容易泄漏的出口。 + message = redact_sensitive_text(cls._error_message(error, "未知错误。")) + raise RuntimeError(f"Responses API 返回错误: {message}") status = cls._field(response, "status") if status not in {"failed", "incomplete", "cancelled"}: @@ -2418,7 +2421,7 @@ def _raise_for_response_error(cls, response: Any) -> None: detail = f"原因: {reason}" if reason else "未提供原因。" else: detail = "未提供原因。" - raise RuntimeError(f"Responses API 响应状态为 {status}: {detail}") + raise RuntimeError(f"Responses API 响应状态为 {status}: {redact_sensitive_text(detail)}") @classmethod def _stream_delta(cls, event: Any) -> str: diff --git a/src/retrieval_test/excel_logger.py b/src/retrieval_test/excel_logger.py index 6c92bf9..076e807 100644 --- a/src/retrieval_test/excel_logger.py +++ b/src/retrieval_test/excel_logger.py @@ -6,6 +6,7 @@ from openpyxl import Workbook # type: ignore[import-untyped] from openpyxl.worksheet.worksheet import Worksheet # type: ignore[import-untyped] +from ..utils.config import get_settings from ..utils.log_manager import get_module_logger logger = get_module_logger(__name__) @@ -16,13 +17,17 @@ class ExcelLogger: 一个用于将召回测试结果记录到 Excel 文件的日志记录器。 """ - def __init__(self, log_dir: str = "data/logs"): + def __init__(self, log_dir: str | None = None): """ 初始化 ExcelLogger。 Args: - log_dir (str): 存储日志文件的目录。 + log_dir (str | None): 存储日志文件的目录。省略时使用 + ``Settings.log_path``,与文本日志落在同一处;否则两者会 + 因相对路径基于 CWD、绝对路径基于 ROOT_DIR 而分叉。 """ + if log_dir is None: + log_dir = get_settings().log_path # 确保日志目录存在 os.makedirs(log_dir, exist_ok=True) diff --git a/tests/providers/test_failure_contracts.py b/tests/providers/test_failure_contracts.py index bca8496..dd8835f 100644 --- a/tests/providers/test_failure_contracts.py +++ b/tests/providers/test_failure_contracts.py @@ -251,3 +251,42 @@ def test_provider_map_modules_are_importable(provider_name, module_name, class_n provider_class = getattr(module, class_name) assert provider_class is not None, provider_name + + +@pytest.mark.parametrize( + ("response", "match"), + [ + ({"error": {"message": "bad key sk-FAKE0000badkey1234567890"}}, "Responses API 返回错误"), + ( + { + "status": "incomplete", + "incomplete_details": {"reason": "bad key sk-FAKE0000badkey1234567890"}, + }, + "响应状态为 incomplete", + ), + ], +) +def test_responses_error_path_redacts_server_message(response, match): + """Responses 错误出口与 Chat Completions 出口必须同样脱敏。 + + 服务端错误消息常回显请求头或 URL;异常文本会进日志与终端,是本项目 + 凭证最容易泄漏的出口。此前 Responses 分支直接拼接原始消息。 + """ + with pytest.raises(RuntimeError, match=match) as excinfo: + OpenAICompatibleProvider._raise_for_response_error(response) + + assert "sk-FAKE0000badkey1234567890" not in str(excinfo.value) + assert "[REDACTED]" in str(excinfo.value) + + +@pytest.mark.parametrize("status", ["failed", "cancelled"]) +def test_responses_error_path_reports_status_without_message(status): + """失败/取消状态不含服务端消息,仍须显式报错而非静默返回。""" + with pytest.raises(RuntimeError, match=f"响应状态为 {status}"): + OpenAICompatibleProvider._raise_for_response_error({"status": status}) + + +@pytest.mark.parametrize("status", ["completed", "in_progress", None]) +def test_responses_error_path_passes_through_when_not_terminal_failure(status): + """非失败状态不是错误,不得抛异常。""" + assert OpenAICompatibleProvider._raise_for_response_error({"status": status}) is None diff --git a/tests/providers/test_rerank_contracts.py b/tests/providers/test_rerank_contracts.py index 654f611..500bb07 100644 --- a/tests/providers/test_rerank_contracts.py +++ b/tests/providers/test_rerank_contracts.py @@ -32,9 +32,20 @@ def test_siliconflow_duplicate_documents_keep_distinct_indices(): @pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) def test_rerank_accepts_top_n_larger_than_document_count(provider_type): + """上游 rerank API 自行截断,top_n 超过文档数属合法输入。""" provider = object.__new__(provider_type) - provider._validate_inputs("query", ["document"], 2) + assert provider._validate_inputs("query", ["document"], 2) is None + + +@pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) +@pytest.mark.parametrize("top_n", [0, -1, True, 1.5, "1", None]) +def test_rerank_rejects_invalid_top_n(provider_type, top_n): + """top_n 必须是 >= 1 的整数;bool 是 int 的子类,须显式排除。""" + provider = object.__new__(provider_type) + + with pytest.raises(ValueError, match="top_n"): + provider._validate_inputs("query", ["document"], top_n) @pytest.mark.parametrize("provider_type", [JinaProvider, SiliconflowRerankProvider]) diff --git a/tests/retrieval/test_parent_child_retrieval.py b/tests/retrieval/test_parent_child_retrieval.py index 979ac37..9e7b34f 100644 --- a/tests/retrieval/test_parent_child_retrieval.py +++ b/tests/retrieval/test_parent_child_retrieval.py @@ -445,3 +445,87 @@ def test_retrieve_documents_rrf_keeps_results_under_default_threshold(monkeypatc assert results assert results[0]["page_content"] == "parent content" assert results[0]["metadata"]["parent_id"] == "parent-1" + + +def _rerank_session_config(top_k): + return SimpleNamespace( + rerank_enabled=True, + active_rerank_configuration="siliconflow", + top_k=top_k, + rerank_configurations={ + "siliconflow": SimpleNamespace(provider="siliconflow", model_name="rerank") + }, + ) + + +def test_rerank_reorders_documents_by_returned_index_and_score(monkeypatch): + """provider 返回乱序 index 时,结果必须按 index 重排并按分数降序。 + + 既有用例的替身恒返回 ``[0]``(恒等置换),重排与 ``sorted(reverse=True)`` + 从未被执行,因此这条路径长期无守卫。 + """ + service = RetrievalService(vector_store=object(), embedding_service=object()) + provider = MagicMock() + + async def fake_arerank(_query, _documents, top_n): + assert top_n == 3 + return [2, 0, 1], [0.9, 0.5, 0.3] + + provider.arerank.side_effect = fake_arerank + monkeypatch.setattr( + "src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", + MagicMock(return_value=provider), + ) + documents = [ + {"page_content": "doc-a", "score": 0.1, "metadata": {}}, + {"page_content": "doc-b", "score": 0.2, "metadata": {}}, + {"page_content": "doc-c", "score": 0.3, "metadata": {}}, + ] + + reranked = asyncio.run( + service._rerank_if_needed("query", documents, _rerank_session_config(top_k=3)) + ) + + assert [doc["page_content"] for doc in reranked] == ["doc-c", "doc-a", "doc-b"] + assert [doc["score"] for doc in reranked] == [0.9, 0.5, 0.3] + # 原列表不得被就地修改 + assert [doc["page_content"] for doc in documents] == ["doc-a", "doc-b", "doc-c"] + + +def test_rerank_output_order_follows_scores_not_provider_order(monkeypatch): + """provider 若按 index 升序返回但分数递减,最终顺序仍应由分数决定。""" + service = RetrievalService(vector_store=object(), embedding_service=object()) + provider = MagicMock() + + async def fake_arerank(_query, _documents, top_n): + return [0, 1, 2], [0.1, 0.9, 0.4] + + provider.arerank.side_effect = fake_arerank + monkeypatch.setattr( + "src.services.retrieval_service.ModelProviderFactory.get_rerank_provider", + MagicMock(return_value=provider), + ) + documents = [{"page_content": f"doc-{name}", "score": 0.0, "metadata": {}} for name in "abc"] + + reranked = asyncio.run( + service._rerank_if_needed("query", documents, _rerank_session_config(top_k=3)) + ) + + assert [doc["page_content"] for doc in reranked] == ["doc-b", "doc-c", "doc-a"] + + +@pytest.mark.parametrize( + ("indices", "scores", "match"), + [ + ([True], [0.9], "index 无效"), + ([0], [True], "score 无效"), + ([0, 0], [0.9, 0.8], "重复 index"), + ([3], [0.9], "index 无效"), + ([-1], [0.9], "index 无效"), + ([0], [float("inf")], "有限数值"), + ], +) +def test_validate_rerank_output_rejects_invalid_provider_results(indices, scores, match): + """契约失败必须是显式异常,而不是让错误顺序静默进入检索结果。""" + with pytest.raises(RuntimeError, match=match): + RetrievalService._validate_rerank_output(indices, scores, document_count=3) From 453d096613fd7c17fffe9f81e6b3dc8964428a76 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 13:50:54 +0800 Subject: [PATCH 24/31] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20None=20?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E5=B4=A9=E6=BA=83=E3=80=81=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E6=9E=9A=E4=B8=BE=E4=B8=8E=E4=BC=AA=E9=80=A0=20schema?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第二批规划审查修复,三处缺陷均独立复现并做红绿验证。 - 修复 Anthropic 响应解析在字段为 None 时崩溃。`field()` 只在键不存在时 返回默认值,而 SDK 的 thinking/redacted_thinking 块会给出 None,此时 `"".join(...)` 抛 `TypeError: expected str instance, NoneType found`。 非流式的正文与推理拼接、流式的 text_delta/thinking_delta/input_json_delta 三处一并加兜底。同类拼接在 Google 与 Volcengine 已有 isinstance 守卫, 仅 Anthropic 缺;回退修复后 4 个用例变红。 - Google `tool_choice` 不再静默接受未知模式。`FunctionCallingConfigMode` 是 大小写不敏感枚举,未命中时会合成一个同名成员、只发 UserWarning,随后被 静默发往服务端;OpenAI 风格的 `{"type": "tool"}` 正会落到这里(已用 `-W error::UserWarning` 复现)。现按已知模式显式映射,未知值报 ValueError 并给出可选值。`ToolConfig` 的未知键也从裸 pydantic ValidationError 归一为 本项目的 ValueError。 - Responses `json_schema` 缺 `schema` 时显式报错。此前回退成 `schema` 本身, 伪造出 `{"type": "json_schema"}` 的 schema 发给服务端,调用方以为拿到了 结构化输出约束而实际没有;同时 `strict` 不再被硬编码的 True 覆盖调用方取值。 - 补充流式重试助手的回归测试。`retry_sync_stream`/`retry_async_stream` 的 建立阶段重试此前零覆盖,现覆盖「建立失败重试」「首事件后不再重试(避免 重复输出)」「空流不算失败」「校验器只作用于首事件」「消费方提前退出时关闭 迭代器」及异步等价场景。 经核实为有意设计不改:`function_response_value` 对 JSON 形态工具结果的解码; 非流式 `invoke` 只产文本(工具调用与截断信号走 `complete()`)。 测试数 1088 -> 1119。 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 6 +- src/providers/anthropic.py | 13 +- src/providers/google.py | 21 ++- src/providers/openai_compatible.py | 20 ++- tests/providers/test_failure_contracts.py | 185 ++++++++++++++++++++++ tests/providers/test_protocol_adapters.py | 140 ++++++++++++++++ 6 files changed, 369 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a093f2..73de28f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ ### 测试与文档 -- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1088。新增断言经红绿验证(回退对应源码后测试变红);其中一部分是**反向守卫**——回退源码不会变红,需要定向变异(例如「让守卫连 Ark 一起拒」)才能验证,这类断言守的是「不能过度收紧」,同样有区分度。 +- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1119。新增断言经红绿验证(回退对应源码后测试变红);其中一部分是**反向守卫**——回退源码不会变红,需要定向变异(例如「让守卫连 Ark 一起拒」)才能验证,这类断言守的是「不能过度收紧」,同样有区分度。 - 修复三处测试有效性缺陷:掩码尾部可见片段的断言用 `startswith("[REDACTED]")`,而掩码前缀在任何实现下都会被替换成 `[REDACTED]`,该断言恒真——尾部片段原样泄漏时也能通过,改为全文相等;短纯字母凭证的 7 个用例里有 4 个取值 ≥12 字符,旧规则本就能命中、对被测属性零区分度,改为真正短于 12 字符的值并断言值本身被替换;掩码线性度用例用单次采样配 100ms 阈值,实测本机 p99.9 即到 100ms、满载时更高,会间歇误报,改为放大规模到线性与二次相差三个数量级、取多轮最小值。 - 为活动快照的 embedding 兼容性检测补充回归测试:快照记录的 embedding provider 或模型名与当前运行配置不一致时必须显式失败,避免用错向量空间后静默产出错误检索结果。 - 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置;新增 `.github/workflows/quality.yml`,在 PR 与 `main` 推送时执行格式化检查、lint、类型检查、安全扫描与完整测试,此前这些工具只在本地手动运行。 @@ -77,6 +77,10 @@ - 补充重排乱序路径与重排契约的回归测试:既有替身恒返回 `[0]`(恒等置换),`_rerank_if_needed` 的重排与按分数降序排序从未被执行,`_validate_rerank_output` 的布尔混入与越界 index 分支也无覆盖。 - 修复无断言测试:`test_rerank_accepts_top_n_larger_than_document_count` 只调用 `_validate_inputs` 而无断言,即使行为变更也不会失败;现补断言并新增 `top_n` 非法值(含 `bool` 混入)的参数化用例。 - 文档校正:`REFACTORING_PLAN.md` 标注为 v1.2.0 历史归档并修正三处与现状不符的表述(`PipelineManager` 实为 `Pipeline`、依赖应声明在 `pyproject.toml`、当时配置格式为 `config.ini`);`README.md` 移除已退役的模型举例、修正 `/config` 可调项描述、补全 `src/` 目录树、把许可说明指向实际存在的许可证文件;`getting-started.md` 移除不存在的 `exit` 退出方式;`developer-guide.md` 补全目录树。 +- 修复 Anthropic 响应解析在字段为 `None` 时崩溃:`field()` 只在键不存在时返回默认值,而 SDK 的 `thinking`/`redacted_thinking` 块会给出 `None`,此时 `"".join(...)` 抛 `TypeError: expected str instance, NoneType found`。非流式的正文与推理拼接、流式的 `text_delta`/`thinking_delta`/`input_json_delta` 三处一并加兜底。同类拼接在 Google 与 Volcengine 已有 `isinstance` 守卫,仅 Anthropic 缺。 +- Google `tool_choice` 不再静默接受未知模式:`FunctionCallingConfigMode` 是大小写不敏感枚举,未命中时会合成一个同名成员、只发 `UserWarning`,随后被静默发往服务端;OpenAI 风格的 `{"type": "tool"}` 正会落到这里。现按已知模式显式映射,未知值报 `ValueError`,并给出可选值。`ToolConfig` 的未知键也从裸 pydantic `ValidationError` 归一为本项目的 `ValueError`。 +- Responses `json_schema` 缺 `schema` 时显式报错:此前会回退成 `schema` 本身,伪造出 `{"type": "json_schema"}` 的 schema 发给服务端,调用方以为拿到了结构化输出约束而实际没有;同时 `strict` 不再被硬编码的 `True` 覆盖调用方取值。 +- 补充流式重试助手的回归测试:`retry_sync_stream`/`retry_async_stream` 的建立阶段重试此前零覆盖,现覆盖「建立失败重试」「首事件后不再重试(避免重复输出)」「空流不算失败」「校验器只作用于首事件」「消费方提前退出时关闭迭代器」以及异步等价场景。 ## [1.3.0] - 2026-03-20 diff --git a/src/providers/anthropic.py b/src/providers/anthropic.py index 187d60c..896f8e6 100644 --- a/src/providers/anthropic.py +++ b/src/providers/anthropic.py @@ -766,11 +766,14 @@ def _build_message_params( @classmethod def _extract_result(cls, response: Any) -> CompletionResult: blocks = field(response, "content", []) or [] + # ``field`` 只在键不存在时返回默认值;键存在而值为 None 时仍返回 None, + # 而 SDK 的部分块(如 thinking / redacted_thinking)确实会给出 None。 + # 直接 join 会抛 ``TypeError: expected str instance, NoneType found``。 text = "".join( - field(block, "text", "") for block in blocks if field(block, "type") == "text" + field(block, "text", "") or "" for block in blocks if field(block, "type") == "text" ) reasoning = "".join( - field(block, "thinking", "") + field(block, "thinking", "") or "" for block in blocks if field(block, "type") in {"thinking", "redacted_thinking"} ) @@ -960,14 +963,14 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven if delta_type == "text_delta": return StreamEvent( type="text_delta", - text=field(delta, "text", ""), + text=field(delta, "text", "") or "", response_id=response_id, raw=event, ) if delta_type == "thinking_delta": return StreamEvent( type="reasoning_delta", - reasoning=field(delta, "thinking", ""), + reasoning=field(delta, "thinking", "") or "", response_id=response_id, raw=event, ) @@ -984,7 +987,7 @@ def _stream_event(cls, event: Any, response_id: str | None = None) -> StreamEven type="tool_call_delta", tool_call={ "index": field(event, "index"), - "arguments": field(delta, "partial_json", ""), + "arguments": field(delta, "partial_json", "") or "", }, response_id=response_id, raw=event, diff --git a/src/providers/google.py b/src/providers/google.py index a49debb..d266fa2 100644 --- a/src/providers/google.py +++ b/src/providers/google.py @@ -1159,7 +1159,12 @@ def _convert_tool_choice(tool_choice: Any) -> Any: if tool_choice is None: return None if isinstance(tool_choice, dict) and "function_calling_config" in tool_choice: - return types.ToolConfig(**tool_choice) + # 直传 SDK 会让未知键冒泡为 pydantic ValidationError,调用方拿到的 + # 是与本项目其它配置错误不一致的异常类型;这里显式归一。 + try: + return types.ToolConfig(**tool_choice) + except Exception as exc: # pydantic ValidationError + raise ValueError(f"Google tool_choice 无效: {exc}") from exc if isinstance(tool_choice, dict): if tool_choice.get("type") == "function": function = tool_choice.get("function", {}) @@ -1176,9 +1181,17 @@ def _convert_tool_choice(tool_choice: Any) -> Any: ) tool_choice = tool_choice.get("type", tool_choice.get("mode", "AUTO")) if isinstance(tool_choice, str): - mode = {"auto": "AUTO", "required": "ANY", "any": "ANY", "none": "NONE"}.get( - tool_choice.lower(), tool_choice.upper() - ) + # ``FunctionCallingConfigMode`` 是大小写不敏感枚举,但未命中时 + # 会合成一个同名字符串枚举成员并只发 UserWarning,随后被静默发往 + # 服务端。OpenAI 风格的 ``{"type": "tool"}`` 正会落到这里,因此 + # 必须先按已知模式映射,未知值显式报错。 + normalized = tool_choice.strip().lower() + mode = {"auto": "AUTO", "required": "ANY", "any": "ANY", "none": "NONE"}.get(normalized) + if mode is None: + raise ValueError( + f"Google tool_choice 不支持的模式: {tool_choice!r};" + "可选值: auto、required、any、none,或 {'type': 'function', ...}。" + ) return types.ToolConfig( function_calling_config=types.FunctionCallingConfig( mode=types.FunctionCallingConfigMode(mode) diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index 2929ca4..4d1dc85 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -1464,15 +1464,23 @@ def _build_responses_request( def _convert_responses_format(response_format: dict[str, Any]) -> dict[str, Any]: if response_format.get("type") == "json_schema": schema = response_format.get("json_schema", response_format) - json_schema = schema.get("schema", {}) if isinstance(schema, Mapping) else {} + if not isinstance(schema, Mapping): + raise ValueError("Responses json_schema 必须是对象。") + # 无 schema 时不能回退成 ``schema`` 本身:那会伪造出一个 + # ``{"type": "json_schema"}`` 的 schema 发给服务端,客户端以为 + # 拿到了结构化输出约束,实际没有。缺 schema 属调用方错误。 + json_schema = schema.get("schema") + if not isinstance(json_schema, Mapping) or not json_schema: + raise ValueError( + "Responses json_schema 缺少 schema 字段;" + "请提供 {'type': 'json_schema', 'json_schema': {'name': ..., 'schema': {...}}}。" + ) return { "format": { "type": "json_schema", - "name": schema.get("name", "response") - if isinstance(schema, Mapping) - else "response", - "schema": json_schema or response_format.get("schema", schema), - "strict": schema.get("strict", True) if isinstance(schema, Mapping) else True, + "name": schema.get("name", "response"), + "schema": json_schema, + "strict": schema.get("strict", True), } } if response_format.get("type") == "json_object": diff --git a/tests/providers/test_failure_contracts.py b/tests/providers/test_failure_contracts.py index dd8835f..451543b 100644 --- a/tests/providers/test_failure_contracts.py +++ b/tests/providers/test_failure_contracts.py @@ -10,6 +10,9 @@ CompletionRequest, LargeLanguageModel, iterate_async, + raise_for_stream_error_event, + retry_async_stream, + retry_sync_stream, ) from src.providers.anthropic import AnthropicProvider from src.providers.factory import ModelProviderFactory @@ -290,3 +293,185 @@ def test_responses_error_path_reports_status_without_message(status): def test_responses_error_path_passes_through_when_not_terminal_failure(status): """非失败状态不是错误,不得抛异常。""" assert OpenAICompatibleProvider._raise_for_response_error({"status": status}) is None + + +# ── 流式重试助手:只重试「首事件之前」的建立阶段 ── +# 这段逻辑是生产流式路径的重试入口,此前完全没有覆盖。 + + +def test_retry_sync_stream_retries_establishment_failure(): + """建立阶段抛错应重试,且只重试一次即可成功。""" + calls = [] + + def factory(): + calls.append(1) + if len(calls) == 1: + raise ConnectionError("建立失败") + return iter(["a", "b"]) + + assert list(retry_sync_stream(factory)) == ["a", "b"] + assert len(calls) == 2 + + +def test_retry_sync_stream_does_not_retry_after_first_event(): + """首事件已产出后不得重试,否则会重复已输出的内容。""" + calls = [] + + def factory(): + calls.append(1) + + def gen(): + yield "first" + raise ConnectionError("消费阶段失败") + + return gen() + + with pytest.raises(ConnectionError): + for _ in retry_sync_stream(factory): + pass + + assert len(calls) == 1 + + +def test_retry_sync_stream_first_event_validator_sees_first_event(): + """首事件必须先过校验器,且校验通过后原样产出。""" + seen = [] + + def validate(event): + seen.append(event) + + assert list(retry_sync_stream(lambda: iter([{"ok": 1}, {"ok": 2}]), validate)) == [ + {"ok": 1}, + {"ok": 2}, + ] + assert seen == [{"ok": 1}] + + +def test_retry_sync_stream_retries_when_first_event_validator_raises_retryable(): + """首事件是 5xx/429 错误事件时,重试发生在消费之前,不会重复输出内容。""" + calls = [] + + def factory(): + calls.append(1) + if len(calls) == 1: + return iter([{"type": "error", "error": {"message": "overloaded", "status_code": 503}}]) + return iter([{"ok": 1}]) + + def validate(event): + raise_for_stream_error_event(event, "Test") + + assert list(retry_sync_stream(factory, validate)) == [{"ok": 1}] + assert len(calls) == 2 + + +def test_retry_sync_stream_does_not_retry_non_retryable_validator_error(): + """校验器抛不可重试错误(如参数错误)时不得重试。""" + calls = [] + + def factory(): + calls.append(1) + return iter([{"bad": True}]) + + def validate(event): + raise ValueError("不可重试") + + with pytest.raises(ValueError, match="不可重试"): + list(retry_sync_stream(factory, validate)) + + assert len(calls) == 1 + + +def test_retry_sync_stream_empty_stream_is_not_an_error(): + """空流不是失败,不得重试也不得抛错。""" + calls = [] + + def factory(): + calls.append(1) + return iter([]) + + assert list(retry_sync_stream(factory)) == [] + assert len(calls) == 1 + + +def test_retry_sync_stream_closes_iterator_on_consumer_abort(): + """消费方提前退出时须关闭底层迭代器,避免连接泄漏。""" + closed = [] + + class ClosingIterator: + def __iter__(self): + return self + + def __next__(self): + return "item" + + def close(self): + closed.append(True) + + assert next(iter(retry_sync_stream(lambda: ClosingIterator()))) == "item" + + assert closed == [True] + + +def test_retry_async_stream_retries_establishment_failure(): + calls = [] + + async def factory(): + calls.append(1) + if len(calls) == 1: + raise ConnectionError("建立失败") + + async def gen(): + yield "a" + yield "b" + + return gen() + + async def collect(): + return [item async for item in retry_async_stream(factory)] + + assert asyncio.run(collect()) == ["a", "b"] + assert len(calls) == 2 + + +def test_retry_async_stream_does_not_retry_after_first_event(): + calls = [] + + async def factory(): + calls.append(1) + + async def gen(): + yield "first" + raise ConnectionError("消费阶段失败") + + return gen() + + async def consume(): + async for _ in retry_async_stream(factory): + pass + + with pytest.raises(ConnectionError): + asyncio.run(consume()) + + assert len(calls) == 1 + + +def test_retry_async_stream_closes_iterator_on_consumer_abort(): + closed = [] + + class ClosingAsyncIterator: + def __aiter__(self): + return self + + async def __anext__(self): + return "item" + + async def aclose(self): + closed.append(True) + + async def consume_one(): + async for _ in retry_async_stream(lambda: ClosingAsyncIterator()): + return + + asyncio.run(consume_one()) + + assert closed == [True] diff --git a/tests/providers/test_protocol_adapters.py b/tests/providers/test_protocol_adapters.py index 635d4c5..7765992 100644 --- a/tests/providers/test_protocol_adapters.py +++ b/tests/providers/test_protocol_adapters.py @@ -3096,3 +3096,143 @@ def test_deepseek_chat_uses_max_tokens_for_explicit_and_configured_limits(): request = provider._build_chat_request(prompt="hi", stream=False) assert request["max_tokens"] == 256 assert "max_completion_tokens" not in request + + +@pytest.mark.parametrize( + "content", + [ + [SimpleNamespace(type="text", text=None)], + [SimpleNamespace(type="thinking", thinking=None)], + [ + SimpleNamespace(type="thinking", thinking=None), + SimpleNamespace(type="text", text=None), + ], + [SimpleNamespace(type="redacted_thinking", thinking=None)], + ], +) +def test_anthropic_extract_result_tolerates_null_block_fields(content): + """块字段存在但值为 None 时不得崩。 + + ``field()`` 只在键不存在时返回默认值;键存在而值为 None 时仍返回 None, + 直接 ``"".join(...)`` 会抛 ``TypeError: expected str instance``。 + SDK 的 thinking / redacted_thinking 块确实会给出 None。 + """ + result = AnthropicProvider._extract_result(SimpleNamespace(content=content, usage=None)) + + assert isinstance(result.text, str) + assert isinstance(result.reasoning, str) + + +def test_anthropic_extract_result_preserves_real_text(): + """守卫不能把正常内容一起吞掉。""" + result = AnthropicProvider._extract_result( + SimpleNamespace( + content=[ + SimpleNamespace(type="thinking", thinking="推理"), + SimpleNamespace(type="text", text="正文"), + ], + usage=None, + ) + ) + + assert result.text == "正文" + assert result.reasoning == "推理" + + +@pytest.mark.parametrize( + ("delta", "expected_type", "expected_value"), + [ + (SimpleNamespace(type="text_delta", text=None), "text_delta", "text"), + (SimpleNamespace(type="thinking_delta", thinking=None), "reasoning_delta", "reasoning"), + ( + SimpleNamespace(type="input_json_delta", partial_json=None), + "tool_call_delta", + "tool_call", + ), + ], +) +def test_anthropic_stream_delta_tolerates_null_fields(delta, expected_type, expected_value): + """流式 delta 的同一处缺陷:None 会让事件构造出非字符串或直接崩。""" + event = AnthropicProvider._stream_event( + SimpleNamespace(type="content_block_delta", index=0, delta=delta), "resp-1" + ) + + assert event.type == expected_type + value = getattr(event, expected_value) + if expected_value == "tool_call": + assert value["arguments"] == "" + else: + assert value == "" + + +@pytest.mark.parametrize("tool_choice", [{"type": "tool"}, {"type": "TOOL"}, {"mode": "bogus"}]) +def test_google_tool_choice_rejects_unknown_mode_instead_of_silent_enum(tool_choice): + """未知模式必须显式报错。 + + ``FunctionCallingConfigMode`` 是大小写不敏感枚举,未命中时会合成一个同名 + 字符串枚举成员、只发 UserWarning,随后被静默发往服务端。OpenAI 风格的 + ``{"type": "tool"}`` 正会落到这里。 + """ + with pytest.raises(ValueError, match="tool_choice"): + GoogleProvider._convert_tool_choice(tool_choice) + + +@pytest.mark.parametrize( + ("tool_choice", "expected"), + [("auto", "AUTO"), ("required", "ANY"), ("any", "ANY"), ("ANY", "ANY"), ("none", "NONE")], +) +def test_google_tool_choice_maps_known_modes(tool_choice, expected): + """已知模式仍须正常映射,收紧不能把合法输入一起拒掉。""" + config = GoogleProvider._convert_tool_choice(tool_choice) + + assert config.function_calling_config.mode.name == expected + + +def test_google_tool_choice_normalizes_tool_config_errors(): + """未知键应归一为本项目的 ValueError,而不是裸的 pydantic ValidationError。""" + with pytest.raises(ValueError, match="tool_choice 无效"): + GoogleProvider._convert_tool_choice( + {"function_calling_config": {"mode": "AUTO"}, "bogus": 1} + ) + + +@pytest.mark.parametrize( + "response_format", + [{"type": "json_schema"}, {"type": "json_schema", "json_schema": {"name": "r"}}], +) +def test_responses_json_schema_requires_schema(response_format): + """缺 schema 不能回退成 schema 本身。 + + 那样会伪造出 ``{"type": "json_schema"}`` 的 schema 发给服务端,客户端以为 + 拿到了结构化输出约束,实际没有。 + """ + with pytest.raises(ValueError, match="缺少 schema"): + OpenAICompatibleProvider._convert_responses_format(response_format) + + +def test_responses_json_schema_preserves_caller_strict(): + """strict 必须透传,不能被硬编码的 True 覆盖。""" + converted = OpenAICompatibleProvider._convert_responses_format( + { + "type": "json_schema", + "json_schema": {"name": "out", "schema": {"type": "object"}, "strict": False}, + } + ) + + assert converted["format"]["strict"] is False + assert converted["format"]["name"] == "out" + assert converted["format"]["schema"] == {"type": "object"} + + +def test_responses_json_schema_accepts_flat_shape(): + """扁平形态(schema/strict 与 type 同级)仍须支持。""" + converted = OpenAICompatibleProvider._convert_responses_format( + {"type": "json_schema", "name": "flat", "schema": {"type": "object"}} + ) + + assert converted["format"] == { + "type": "json_schema", + "name": "flat", + "schema": {"type": "object"}, + "strict": True, + } From b424bb34ed099cdf3b16788710185b278d9e5b38 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 14:44:01 +0800 Subject: [PATCH 25/31] =?UTF-8?q?fix:=20Ark=20=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E8=A1=A5=20type=20=E9=94=AE=EF=BC=8C=E6=95=B0?= =?UTF-8?q?=E5=80=BC=E9=85=8D=E7=BD=AE=E8=A1=A5=E9=BD=90=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E6=9C=9F=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第二批维护复核发现并修复两处真实缺陷: - Ark Chat Completions 工具调用缺 type 键:`_extract_result` 的 Chat Completions 分支只产出 `{"id", "name", "arguments"}`,而同一函数的 Responses 分支与 `openai_compatible.py._extract_tool_calls` 都产出契约 声明的扁平 `{"id", "type", "name", "arguments"}`(model_provider.py:343 与 docs/user_guide/llm-providers.md:108 双重写明)。调用方按契约回填 `role=tool` 历史时会配不上,此前的 OpenAI 侧测试输入带了 type 却只断言 name,Ark 侧则完全没有 CC 用例,因而从未暴露。 - 8 个数值配置字段缺加载期校验:`kb_chunk_size=0` 会一路通过配置校验、直到 langchain 在分片阶段才抛 `chunk_size must be > 0`;负权重会反向加成分数。 UI 层(src/ui/config_menu.py)对权重已有 0..1 校验,TOML/env 路径没有。 另用 model_validator 补上「overlap 必须严格小于 size」这条无法用单字段 表达、但会让切分器无法推进的跨字段约束。 两处修复均经红-绿验证(回退源码 → 测试恰好失败 → 恢复 → 通过)。 验证命令: - uv run pytest -q → 1135 passed(基线 1119,新增 16 条) - uv run ruff check / ruff format --check → 通过 - uv run mypy --cache-dir /tmp/pyrag-kit-mypy main.py src → 无问题 - uv run bandit -r main.py src scripts -ll → 0 issues - uv lock --check / git diff --check / compileall → 通过 - uv run main.py --smoke-test → smoke test ok 配置影响:越界的 chunk/weight/retention 值现在在加载期显式报错,此前被静默接受。 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + src/providers/volcengine.py | 6 +++ src/utils/config.py | 47 ++++++++++++++++ tests/providers/test_sdk_capabilities.py | 69 ++++++++++++++++++++++++ tests/test_config.py | 53 ++++++++++++++++++ 5 files changed, 177 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73de28f..48c2d40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,8 @@ - Google `tool_choice` 不再静默接受未知模式:`FunctionCallingConfigMode` 是大小写不敏感枚举,未命中时会合成一个同名成员、只发 `UserWarning`,随后被静默发往服务端;OpenAI 风格的 `{"type": "tool"}` 正会落到这里。现按已知模式显式映射,未知值报 `ValueError`,并给出可选值。`ToolConfig` 的未知键也从裸 pydantic `ValidationError` 归一为本项目的 `ValueError`。 - Responses `json_schema` 缺 `schema` 时显式报错:此前会回退成 `schema` 本身,伪造出 `{"type": "json_schema"}` 的 schema 发给服务端,调用方以为拿到了结构化输出约束而实际没有;同时 `strict` 不再被硬编码的 `True` 覆盖调用方取值。 - 补充流式重试助手的回归测试:`retry_sync_stream`/`retry_async_stream` 的建立阶段重试此前零覆盖,现覆盖「建立失败重试」「首事件后不再重试(避免重复输出)」「空流不算失败」「校验器只作用于首事件」「消费方提前退出时关闭迭代器」以及异步等价场景。 +- 修复 Ark Chat Completions 工具调用缺 `type` 键:`VolcengineProvider._extract_result` 的 Chat Completions 分支只产出 `{"id", "name", "arguments"}`,而同一函数的 Responses 分支与 `openai_compatible.py` 的 `_extract_tool_calls` 都产出契约声明的扁平 `{"id", "type", "name", "arguments"}`(`model_provider.py:343` 与 `docs/user_guide/llm-providers.md:108` 双重写明)。调用方按契约回填 `role=tool` 历史时会配不上;现补 `type`(缺省 `function`)并新增两条钉住该键与逐键对齐 OpenAI 兼容侧的回归测试。 +- 数值配置补齐加载期校验:`log_retention_days`、`kb_chunk_size`、`kb_chunk_overlap`、`kb_child_chunk_size`、`kb_child_chunk_overlap`、`kb_embedding_batch_size`、`chat_vector_weight`、`chat_keyword_weight` 此前接受任意数值,`kb_chunk_size=0` 会一路通过配置校验、直到 langchain 在分片阶段才抛 `chunk_size must be > 0`,负权重会反向加成分数;而 UI 层(`src/ui/config_menu.py`)对权重已有 `0..1` 校验,TOML/env 路径没有,形成不对称。现按字段加 validator,并用 `model_validator` 补上「overlap 必须严格小于 size」这条无法用单字段表达、但会让切分器无法推进的跨字段约束。 ## [1.3.0] - 2026-03-20 diff --git a/src/providers/volcengine.py b/src/providers/volcengine.py index 88e8da6..ec86f02 100644 --- a/src/providers/volcengine.py +++ b/src/providers/volcengine.py @@ -1452,6 +1452,12 @@ def _extract_result(response: Any) -> CompletionResult: calls.append( { "id": field(call, "id"), + # 与 openai_compatible.py 的 _extract_tool_calls 及本函数 + # 的 Responses 分支(下方 function_call/custom_tool_call) + # 对齐:CompletionResult.tool_calls 的契约是扁平的 + # {"id", "type", "name", "arguments"}。缺 type 会让调用方 + # 回填的 assistant 历史不符合 OpenAI Chat Completions 形状。 + "type": field(call, "type", "function"), "name": field(fn, "name"), "arguments": normalize_tool_arguments(field(fn, "arguments", "")), } diff --git a/src/utils/config.py b/src/utils/config.py index ace32a8..9bebb43 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -315,6 +315,53 @@ def validate_chat_score_threshold(cls, value: float) -> float: raise ValueError("chat_score_threshold 必须在 0 到 1 之间。") return value + @field_validator("log_retention_days") + @classmethod + def validate_log_retention_days(cls, value: int) -> int: + if isinstance(value, bool) or value < 1: + raise ValueError("log_retention_days 必须是大于等于 1 的整数。") + return value + + @field_validator( + "kb_chunk_size", + "kb_child_chunk_size", + "kb_embedding_batch_size", + ) + @classmethod + def validate_positive_sizes(cls, value: int) -> int: + if isinstance(value, bool) or value < 1: + raise ValueError( + "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size 必须是大于等于 1 的整数。" + ) + return value + + @field_validator("kb_chunk_overlap", "kb_child_chunk_overlap") + @classmethod + def validate_non_negative_overlap(cls, value: int) -> int: + if isinstance(value, bool) or value < 0: + raise ValueError("kb_chunk_overlap/kb_child_chunk_overlap 必须是非负整数。") + return value + + @field_validator("chat_vector_weight", "chat_keyword_weight") + @classmethod + def validate_hybrid_weights(cls, value: float) -> float: + if isinstance(value, bool) or not 0 <= value <= 1: + raise ValueError("chat_vector_weight/chat_keyword_weight 必须在 0 到 1 之间。") + return value + + @model_validator(mode="after") + def validate_overlap_smaller_than_size(self) -> "Settings": + """overlap 必须严格小于 size,否则分片无法推进。 + + ``chunk_size == chunk_overlap`` 会让切分器无法前进(得到空分片或 + 死循环),且这一约束无法用单字段 validator 表达。 + """ + if self.kb_chunk_overlap >= self.kb_chunk_size: + raise ValueError("kb_chunk_overlap 必须小于 kb_chunk_size。") + if self.kb_child_chunk_overlap >= self.kb_child_chunk_size: + raise ValueError("kb_child_chunk_overlap 必须小于 kb_child_chunk_size。") + return self + @field_validator("log_level", mode="before") @classmethod def validate_log_level(cls, v: str) -> str: diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index 8ab4343..2ba6a47 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -2455,6 +2455,75 @@ def test_ark_non_stream_result_preserves_chat_reasoning_content(): assert result.reasoning == "思考过程" +def test_ark_chat_completions_tool_calls_carry_type_key(): + """Ark 的 Chat Completions 分支必须产出契约形状的 tool_calls。 + + ``CompletionResult.tool_calls`` 的契约(``model_provider.py:343`` 与 + ``docs/user_guide/llm-providers.md:108``)是扁平的 + ``{"id", "type", "name", "arguments"}``。此前 Ark 的 CC 分支漏掉 + ``type``,而 Responses 分支(同一函数内)却有——两条分支形状不一致。 + 本测试直接钉住该键,并断言它与 OpenAI 兼容侧产出完全一致。 + """ + response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content=None, + tool_calls=[ + SimpleNamespace( + id="call-1", + type="function", + function=SimpleNamespace(name="lookup", arguments='{"id": 1}'), + ) + ], + ), + finish_reason="tool_calls", + ) + ], + usage=None, + output=None, + output_text=None, + ) + + result = VolcengineProvider._extract_result(response) + + assert len(result.tool_calls) == 1 + call = result.tool_calls[0] + assert call["type"] == "function" + assert set(call) == {"id", "type", "name", "arguments"} + + # 与 OpenAI 兼容侧逐键对齐,防止两条路径再次分叉。 + oai_calls = OpenAICompatibleProvider._extract_tool_calls(response) + assert call == oai_calls[0] + + +def test_ark_chat_completions_tool_calls_default_type_to_function(): + """Ark 未回传 ``type`` 时也要补 ``function``,与 openai_compatible 一致。""" + response = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content=None, + tool_calls=[ + SimpleNamespace( + id="call-2", + function=SimpleNamespace(name="lookup", arguments="{}"), + ) + ], + ), + finish_reason="tool_calls", + ) + ], + usage=None, + output=None, + output_text=None, + ) + + result = VolcengineProvider._extract_result(response) + + assert result.tool_calls[0]["type"] == "function" + + def test_ark_responses_result_does_not_replace_answer_with_reasoning_text(): """正文与 reasoning 必须分开取。 diff --git a/tests/test_config.py b/tests/test_config.py index 6b53bbb..f18f001 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -77,6 +77,59 @@ def test_settings_model_validation(): Settings(chat_score_threshold=1.1) +@pytest.mark.parametrize( + ("field_name", "bad_value"), + [ + ("log_retention_days", -5), + ("log_retention_days", 0), + ("kb_chunk_size", 0), + ("kb_chunk_size", -100), + ("kb_chunk_overlap", -1), + ("kb_child_chunk_size", 0), + ("kb_child_chunk_overlap", -1), + ("kb_embedding_batch_size", 0), + ("kb_embedding_batch_size", -1), + ("chat_vector_weight", -1.0), + ("chat_vector_weight", 5.0), + ("chat_keyword_weight", 2.0), + ], +) +def test_numeric_settings_reject_illegal_values(field_name, bad_value): + """数值字段的非法值必须在加载期报错,而不是拖到分片/检索期。 + + ``kb_chunk_size=0`` 此前会一路通过配置校验,直到 langchain 在分片阶段 + 才抛 ``chunk_size must be > 0``;负权重会反向加成分数。UI 层 + (``src/ui/config_menu.py``)已有 0..1 校验,TOML/env 路径此前没有。 + """ + with pytest.raises(ValidationError, match=field_name): + Settings(**{field_name: bad_value}) + + +def test_chunk_overlap_must_be_strictly_smaller_than_chunk_size(): + """overlap 必须严格小于 size,否则分片无法推进(会得到空分片或死循环)。""" + with pytest.raises(ValidationError, match="kb_chunk_overlap"): + Settings(kb_chunk_size=100, kb_chunk_overlap=100) + with pytest.raises(ValidationError, match="kb_child_chunk_overlap"): + Settings(kb_child_chunk_size=50, kb_child_chunk_overlap=50) + + +def test_numeric_settings_accept_legal_boundary_values(): + """合法边界值必须仍然可用,避免校验过紧。""" + settings = Settings( + log_retention_days=1, + kb_chunk_size=1, + kb_chunk_overlap=0, + kb_child_chunk_size=1, + kb_child_chunk_overlap=0, + kb_embedding_batch_size=1, + chat_vector_weight=0.0, + chat_keyword_weight=1.0, + ) + assert settings.kb_chunk_overlap == 0 + assert settings.chat_vector_weight == 0.0 + assert settings.chat_keyword_weight == 1.0 + + def test_settings_splitter_separators_empty_string_falls_back_to_default(): settings = Settings(kb_splitter_separators="") assert settings.kb_splitter_separators == ["###"] From 0cb770a35730c90229d8de38710800559e4ab961 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 15:08:59 +0800 Subject: [PATCH 26/31] =?UTF-8?q?refactor:=20=E6=B8=85=E7=90=86=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=8E=E9=87=8D=E5=A4=8D=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=EF=BC=8C=E5=8A=A0=E5=9B=BA=E5=8F=91=E5=B8=83=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本轮为「发布前最终复核」的可不修项清理,全部为零功能影响或纯加固, 与上一提交(契约缺陷修复)分开,以保持两个 diff 各自可审。 死代码与重复实现: - 删除 config.py 的向后兼容层 get_backward_compatible_configs()(85 行, 全仓零调用)及其失效的悬空注释。 - 删除 LargeLanguageModel.supports()(零调用)。保留它读取的 capabilities—— factory.capabilities() 经 getattr 动态读取该属性,是活数据,连带删除会 让能力报告静默变成空集。 - 删除 volcengine._ARK_CLASSIFICATION_KEYS(纯冗余:classify() 的三个同名 形参 query/model/labels 无法落进 **kwargs,常量减内联校验集恰好等于这 三个;真正的校验由内联集合完成且完整)。 - 删除 FaissStore 中从未获取的 asyncio.Lock 及其函数内局部 import。 - 简化 anthropic.py 中条件永不产生可区分行为的恒等 if/else。 - openai_compatible._field 与 model_provider.field 逐字节相同却各存一份, 且被 143 处调用(self 28 + cls 115)。删重复定义,统一复用 field。 - volcengine 的 4 个 helper 是 openai_compatible 的逐字节副本,改为委托。 - jina 与 siliconflow_rerank 存在 4 处真实语义差异,有意保持独立实现; 改为在两侧 docstring 写明差异清单与「改动必须同步」的约束。 依赖与文件: - 删除孤儿 logo_ASCII.py(零引用、不在构建/CI 配置、create_gradient 与 main.py 逐字节重复、且未格式化)。 - 移除 langchain 与 langchain-community。保留 langchain-core 与 requests: 前者是 langchain-text-splitters 的必需依赖,后者是 google-genai 与 tiktoken 的必需依赖,删除会破坏真实依赖链。 安全与流程加固: - Google tool_choice 的异常文本在源头脱敏。裸 pydantic ValidationError 会把 违规取值原文写进 input_value=...,而 types.ToolConfig 的键集里就有 api_key 这类凭证形态的键。两个消毒器都能拦住,但源头收口才是纵深防御。 - release.yml 手动发布路径补交叉校验:workflow_dispatch 的 tag/version 此前 直接透传,会发出与 pyproject.toml/CHANGELOG.md 不符的 release。现校验 version 与 pyproject.toml 一致、tag 等于 v{version}、且 CHANGELOG 有该条目。 验证命令: - uv run pytest -q → 1135 passed(与上一提交相同,零行为变更) - uv run ruff check / ruff format --check → 通过 - uv run mypy --cache-dir /tmp/pyrag-kit-mypy main.py src → 无问题 - uv run bandit -r main.py src scripts -ll → 0 issues - uv lock --check → Resolved 88 packages(原 107,移除 2 个直接依赖及其传递树) - uv run python -m compileall / git diff --check → 通过 - uv run main.py --smoke-test → smoke test ok - release.yml → yaml.safe_load 通过,Validate 步骤已在 publish 步骤序列中 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 28 + CHANGELOG.md | 9 + logo_ASCII.py | 35 - pyproject.toml | 2 - src/providers/__base__/model_provider.py | 4 - src/providers/anthropic.py | 5 +- src/providers/google.py | 7 +- src/providers/jina.py | 11 +- src/providers/openai_compatible.py | 279 ++++---- src/providers/siliconflow_rerank.py | 11 +- src/providers/volcengine.py | 64 +- src/retrieval/vdb/faiss_store.py | 3 - src/utils/config.py | 101 --- uv.lock | 861 ----------------------- 14 files changed, 202 insertions(+), 1218 deletions(-) delete mode 100644 logo_ASCII.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7e63f6..fed185d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,34 @@ jobs: echo "version=${version}" >> "${GITHUB_OUTPUT}" fi + - name: Validate version metadata + shell: bash + run: | + set -euo pipefail + tag="${{ steps.release_meta.outputs.tag }}" + version="${{ steps.release_meta.outputs.version }}" + + # 手动 workflow_dispatch 的 tag/version 是手填的,必须与仓库中的 + # 唯一真源交叉校验:否则会发出一个版本号与产物/说明不符的 release。 + pyproject_version="$(python -c "import tomllib, pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])")" + if [ "${version}" != "${pyproject_version}" ]; then + echo "版本不匹配: 输入 ${version} vs pyproject.toml ${pyproject_version}" >&2 + exit 1 + fi + + expected_tag="v${pyproject_version}" + if [ "${tag}" != "${expected_tag}" ]; then + echo "标签不匹配: 输入 ${tag} vs 期望 ${expected_tag}" >&2 + exit 1 + fi + + # CHANGELOG 必须已有该版本条目(extract_release_notes 也会检查, + # 但在这里提前失败能给出更清楚的错误位置)。 + if ! grep -q "^## \[${pyproject_version}\]" CHANGELOG.md; then + echo "CHANGELOG.md 中未找到版本 ${pyproject_version} 的条目。" >&2 + exit 1 + fi + - name: Extract release notes run: python scripts/extract_release_notes.py --version "${{ steps.release_meta.outputs.version }}" --output RELEASE_NOTES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c2d40..9fd95a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,15 @@ - 修复 Ark Chat Completions 工具调用缺 `type` 键:`VolcengineProvider._extract_result` 的 Chat Completions 分支只产出 `{"id", "name", "arguments"}`,而同一函数的 Responses 分支与 `openai_compatible.py` 的 `_extract_tool_calls` 都产出契约声明的扁平 `{"id", "type", "name", "arguments"}`(`model_provider.py:343` 与 `docs/user_guide/llm-providers.md:108` 双重写明)。调用方按契约回填 `role=tool` 历史时会配不上;现补 `type`(缺省 `function`)并新增两条钉住该键与逐键对齐 OpenAI 兼容侧的回归测试。 - 数值配置补齐加载期校验:`log_retention_days`、`kb_chunk_size`、`kb_chunk_overlap`、`kb_child_chunk_size`、`kb_child_chunk_overlap`、`kb_embedding_batch_size`、`chat_vector_weight`、`chat_keyword_weight` 此前接受任意数值,`kb_chunk_size=0` 会一路通过配置校验、直到 langchain 在分片阶段才抛 `chunk_size must be > 0`,负权重会反向加成分数;而 UI 层(`src/ui/config_menu.py`)对权重已有 `0..1` 校验,TOML/env 路径没有,形成不对称。现按字段加 validator,并用 `model_validator` 补上「overlap 必须严格小于 size」这条无法用单字段表达、但会让切分器无法推进的跨字段约束。 +- 清理死代码与重复实现(零功能影响,单独一次提交以便审阅):删除 `config.py` 中零调用的向后兼容层 `get_backward_compatible_configs()`(85 行)及其失效注释;删除 `LargeLanguageModel.supports()`(零调用,但**保留**它读取的 `capabilities`——那是 `factory.capabilities()` 经 `getattr` 读取的活数据);删除 `volcengine._ARK_CLASSIFICATION_KEYS`(纯冗余,`classify()` 的三个同名形参无法落进 `**kwargs`,真正的校验由内联集合完成且完整);删除 `faiss_store.FaissStore` 中从未获取的 `asyncio.Lock` 及其函数内局部 import;简化 `anthropic.py` 中条件永不产生可区分行为的恒等 `if/else`。 +- 消除 `openai_compatible.py` 与 `model_provider.field()` 的重复实现:`_field` 与 `field` 逐字节相同却各存一份,且被 143 处调用(`self._field` 28 + `cls._field` 115)。现删除重复定义、统一复用 `field`,并按字母序补入 import。 +- `volcengine` 的 4 个 helper(`_merge_options`/`_validate_model_options`/`_validated_extra_body`/`_merge_extra_body`)此前是 `openai_compatible` 的逐字节副本,现改为委托到后者,只保留一份实现,消除漂移风险。 +- `jina.py` 与 `siliconflow_rerank.py` 是近似副本(行级相似度约 0.78,7 个方法同名,`_get_headers` 逐字节相同),但存在 4 处真实语义差异(`_OPTION_KEYS`、`_base_url` 来源、构造期 base_url 校验、`_parse_response` 的 `results` 校验时机)。**有意保持独立实现**,改在两侧 docstring 中写明差异清单与「改动必须同步」的约束,而不再引入一层抽象。 +- 删除孤儿文件 `logo_ASCII.py`:全仓零引用、不在任何构建/CI 配置中,其 `create_gradient` 与 `main.py` 的同名函数逐字节重复,且未格式化(一旦纳入 `quality.yml` 扫描范围会直接失败)。 +- 移除 4 个从未被 import 的依赖中的 2 个:`langchain` 与 `langchain-community`。保留 `langchain-core`(`langchain-text-splitters` 的必需依赖)与 `requests`(`google-genai`、`tiktoken` 的必需依赖)——它们的「未使用」是传递依赖的正常形态,删除会破坏真实依赖链。 +- Google `tool_choice` 的异常文本在源头脱敏:裸 pydantic `ValidationError` 会把违规取值原文写进 `input_value=...`,而 `types.ToolConfig` 允许的键集里就有 `api_key` 这类凭证形态的键。两个消毒器(`redact_sensitive_text`、`safe_exception_text`)虽然都能拦住,但在拼消息前收口才是纵深防御。 +- `release.yml` 的手动发布路径补交叉校验:`workflow_dispatch` 的 `tag`/`version` 是手填的,此前直接透传,会发出一个版本号与 `pyproject.toml`/`CHANGELOG.md` 不符的 release。现校验 `version` 与 `pyproject.toml` 一致、`tag` 等于 `v{version}`、且 CHANGELOG 已有该版本条目。 + ## [1.3.0] - 2026-03-20 ### 运行与配置 diff --git a/logo_ASCII.py b/logo_ASCII.py deleted file mode 100644 index fe82e7b..0000000 --- a/logo_ASCII.py +++ /dev/null @@ -1,35 +0,0 @@ -import pyfiglet -from rich.console import Console -from rich.text import Text - -console = Console() - - -def create_gradient( - text: str, start_color: tuple[int, int, int], end_color: tuple[int, int, int] -) -> Text: - """为文本创建从左到右的水平颜色渐变效果。""" - text_obj = Text() - total_length = len(text) - for i, char in enumerate(text): - r = int(start_color[0] + (end_color[0] - start_color[0]) * (i / max(1, total_length - 1))) - g = int(start_color[1] + (end_color[1] - start_color[1]) * (i / max(1, total_length - 1))) - b = int(start_color[2] + (end_color[2] - start_color[2]) * (i / max(1, total_length - 1))) - text_obj.append(char, style=f"rgb({r},{g},{b})") - return text_obj - -def display_banner(): - """显示程序的启动横幅。""" - # 使用 'big' 字体 - fig = pyfiglet.Figlet(font='big') - banner_text = fig.renderText('PyRAG-Kit') - - # 定义渐变色 (左蓝右红) - blue = (0, 0, 255) - red = (255, 0, 0) - - gradient_banner = create_gradient(banner_text, blue, red) - console.print(gradient_banner) - -if __name__ == "__main__": - display_banner() diff --git a/pyproject.toml b/pyproject.toml index 5b97678..7371c27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,8 +12,6 @@ dependencies = [ "google-genai>=2.22.0", "httpx>=0.28.1", "jieba>=0.42.1", - "langchain>=1.4.0", - "langchain-community>=0.4.2", "langchain-core>=1.6.2", "langchain-text-splitters>=1.1.2", "numpy>=2.4.6", diff --git a/src/providers/__base__/model_provider.py b/src/providers/__base__/model_provider.py index 95d9df1..b1b7c0e 100644 --- a/src/providers/__base__/model_provider.py +++ b/src/providers/__base__/model_provider.py @@ -1787,10 +1787,6 @@ def __init_subclass__(cls, **kwargs: Any) -> None: if _is_provider_resource_method(name, method): setattr(cls, name, _guard_provider_resource_method(method)) - @classmethod - def supports(cls, capability: str) -> bool: - return capability in cls.capabilities - def _require_provider_resource( self, method_name: str, kwargs: Mapping[str, Any] | None = None ) -> None: diff --git a/src/providers/anthropic.py b/src/providers/anthropic.py index 896f8e6..41c5d6f 100644 --- a/src/providers/anthropic.py +++ b/src/providers/anthropic.py @@ -395,9 +395,10 @@ def _convert_content(content: Any) -> Any: ) else: converted.append({"type": "image", "source": {"type": "url", "url": image_url}}) - elif part_type == "tool_result": - converted.append(part) else: + # tool_result 与其它的非文本块都原样透传;此前这里写成 + # ``elif part_type == "tool_result"`` + 恒等 ``else``,两个分支 + # 逐字节相同,条件永不产生可区分行为。 converted.append(part) return converted diff --git a/src/providers/google.py b/src/providers/google.py index d266fa2..a626dfa 100644 --- a/src/providers/google.py +++ b/src/providers/google.py @@ -1164,7 +1164,12 @@ def _convert_tool_choice(tool_choice: Any) -> Any: try: return types.ToolConfig(**tool_choice) except Exception as exc: # pydantic ValidationError - raise ValueError(f"Google tool_choice 无效: {exc}") from exc + # pydantic 会把违规取值原文写进 ``input_value=...``,调用方若直接 + # ``str(exc)`` 就可能把凭证形态的值带出去。两个消毒器虽然都能 + # 拦住(见 test_security_boundaries),但在源头收口才是纵深防御。 + raise ValueError( + f"Google tool_choice 无效: {redact_sensitive_text(str(exc))}" + ) from exc if isinstance(tool_choice, dict): if tool_choice.get("type") == "function": function = tool_choice.get("function", {}) diff --git a/src/providers/jina.py b/src/providers/jina.py index 9511565..07247a9 100644 --- a/src/providers/jina.py +++ b/src/providers/jina.py @@ -18,8 +18,15 @@ class JinaProvider(RerankModel): - """ - Jina Rerank模型提供商。 + """Jina Rerank Provider(HTTP JSON)。 + + 本类与 ``siliconflow_rerank.py`` 是近似副本(行级相似度约 0.78,7 个方法同名, + ``_get_headers`` 逐字节相同)。两者**有意保持独立实现**,因为存在 4 处真实 + 语义差异:``_OPTION_KEYS``(本类多 3 个 Jina 专有键)、``_base_url`` 来源 + (本类硬编码,SiliconFlow 走 settings)、构造期 base_url 校验、以及 + ``_parse_response`` 里 ``results`` 类型校验的时机。 + **修改任一侧的校验逻辑、payload 构造或响应解析时,必须同步另一侧。** + 已注入 CSE 性能传感器与 tenacity 重试机制。 """ diff --git a/src/providers/openai_compatible.py b/src/providers/openai_compatible.py index 4d1dc85..20cc48f 100644 --- a/src/providers/openai_compatible.py +++ b/src/providers/openai_compatible.py @@ -19,6 +19,7 @@ close_resource_sync, coerce_completion_request, content_to_text, + field, is_retryable_error, merge_tool_call_fragment, normalize_embedding_vector, @@ -457,10 +458,10 @@ def _response_retrieve_kwargs(cls, params: Mapping[str, Any]) -> dict[str, Any]: } def _poll_background_response(self, response: Any, params: Mapping[str, Any]) -> Any: - status = self._field(response, "status") + status = field(response, "status") if status not in {"queued", "in_progress", "pending"}: return response - response_id = self._field(response, "id") + response_id = field(response, "id") if not response_id: raise RuntimeError("Responses background 响应缺少 id,无法轮询。") interval, timeout = self._background_poll_config() @@ -477,16 +478,16 @@ def _poll_background_response(self, response: Any, params: Mapping[str, Any]) -> response = retry_sync_call( lambda: self._get_client().responses.retrieve(response_id, **retrieve_kwargs) ) - status = self._field(response, "status") + status = field(response, "status") return response async def _poll_background_response_async( self, response: Any, params: Mapping[str, Any] ) -> Any: - status = self._field(response, "status") + status = field(response, "status") if status not in {"queued", "in_progress", "pending"}: return response - response_id = self._field(response, "id") + response_id = field(response, "id") if not response_id: raise RuntimeError("Responses background 响应缺少 id,无法轮询。") interval, timeout = self._background_poll_config() @@ -503,7 +504,7 @@ async def _poll_background_response_async( response = await retry_async_call( lambda: self._get_aclient().responses.retrieve(response_id, **retrieve_kwargs) ) - status = self._field(response, "status") + status = field(response, "status") return response def _compat_extra_options(self) -> dict[str, Any]: @@ -1489,30 +1490,24 @@ def _convert_responses_format(response_format: dict[str, Any]) -> dict[str, Any] return response_format raise ValueError("Responses response_format 仅支持 json_schema 或 json_object。") - @staticmethod - def _field(value: Any, name: str, default: Any = None) -> Any: - if isinstance(value, Mapping): - return value.get(name, default) - return getattr(value, name, default) - @classmethod def _extract_response_text(cls, response: Any) -> str: - output_text = cls._field(response, "output_text") + output_text = field(response, "output_text") if isinstance(output_text, str): return output_text chunks: list[str] = [] - choices = cls._field(response, "choices", []) or [] + choices = field(response, "choices", []) or [] for choice in choices: - message = cls._field(choice, "message") or cls._field(choice, "delta") - text = cls._field(message, "content") + message = field(choice, "message") or field(choice, "delta") + text = field(message, "content") if text: chunks.append(content_to_text(text)) if chunks: return "".join(chunks) - for item in cls._field(response, "output", []) or []: - for content in cls._field(item, "content", []) or []: - text = cls._field(content, "text") + for item in field(response, "output", []) or []: + for content in field(item, "content", []) or []: + text = field(content, "text") if isinstance(text, str): chunks.append(text) return "".join(chunks) @@ -1520,30 +1515,28 @@ def _extract_response_text(cls, response: Any) -> str: @classmethod def _extract_tool_calls(cls, response: Any) -> list[dict[str, Any]]: calls: list[dict[str, Any]] = [] - choices = cls._field(response, "choices", []) or [] + choices = field(response, "choices", []) or [] for choice in choices: - message = cls._field(choice, "message") or cls._field(choice, "delta") - for call in cls._field(message, "tool_calls", []) or []: - function = cls._field(call, "function") + message = field(choice, "message") or field(choice, "delta") + for call in field(message, "tool_calls", []) or []: + function = field(call, "function") calls.append( { - "id": cls._field(call, "id"), - "type": cls._field(call, "type", "function"), - "name": cls._field(function, "name"), - "arguments": normalize_tool_arguments( - cls._field(function, "arguments", "") - ), + "id": field(call, "id"), + "type": field(call, "type", "function"), + "name": field(function, "name"), + "arguments": normalize_tool_arguments(field(function, "arguments", "")), } ) - for item in cls._field(response, "output", []) or []: - if cls._field(item, "type") in {"function_call", "custom_tool_call"}: + for item in field(response, "output", []) or []: + if field(item, "type") in {"function_call", "custom_tool_call"}: calls.append( { - "id": cls._field(item, "call_id") or cls._field(item, "id"), - "type": cls._field(item, "type"), - "name": cls._field(item, "name"), + "id": field(item, "call_id") or field(item, "id"), + "type": field(item, "type"), + "name": field(item, "name"), "arguments": normalize_tool_arguments( - cls._field(item, "arguments", cls._field(item, "input", "")) + field(item, "arguments", field(item, "input", "")) ), } ) @@ -1551,33 +1544,33 @@ def _extract_tool_calls(cls, response: Any) -> list[dict[str, Any]]: @classmethod def _extract_usage(cls, response: Any) -> dict[str, Any]: - return normalize_usage(cls._field(response, "usage")) + return normalize_usage(field(response, "usage")) @classmethod def _extract_reasoning(cls, response: Any) -> str: values: list[str] = [] - choices = cls._field(response, "choices", []) or [] + choices = field(response, "choices", []) or [] for choice in choices: - message = cls._field(choice, "message") or cls._field(choice, "delta") + message = field(choice, "message") or field(choice, "delta") for name in ("reasoning_content", "reasoning"): - value = cls._field(message, name) + value = field(message, name) if isinstance(value, str): values.append(value) - for item in cls._field(response, "output", []) or []: - if cls._field(item, "type") in {"reasoning", "reasoning_item"}: - summary = cls._field(item, "summary") + for item in field(response, "output", []) or []: + if field(item, "type") in {"reasoning", "reasoning_item"}: + summary = field(item, "summary") if isinstance(summary, str): values.append(summary) elif isinstance(summary, (list, tuple)): for entry in summary: - text = cls._field(entry, "text") + text = field(entry, "text") if isinstance(text, str): values.append(text) - for content in cls._field(item, "content", []) or []: - text = cls._field(content, "text") + for content in field(item, "content", []) or []: + text = field(content, "text") if isinstance(text, str): values.append(text) - fallback = cls._field(item, "text") + fallback = field(item, "text") if isinstance(fallback, str): values.append(fallback) return "".join(values) @@ -1585,20 +1578,20 @@ def _extract_reasoning(cls, response: Any) -> str: @classmethod def _extract_refusal(cls, response: Any) -> str | None: """提取 Chat Completions 与 Responses 的统一拒答文本。""" - choices = cls._field(response, "choices", []) or [] + choices = field(response, "choices", []) or [] for choice in choices: - message = cls._field(choice, "message") or cls._field(choice, "delta") - refusal = cls._field(message, "refusal") + message = field(choice, "message") or field(choice, "delta") + refusal = field(message, "refusal") if isinstance(refusal, str) and refusal: return refusal - for item in cls._field(response, "output", []) or []: - refusal = cls._field(item, "refusal") + for item in field(response, "output", []) or []: + refusal = field(item, "refusal") if isinstance(refusal, str) and refusal: return refusal - for content in cls._field(item, "content", []) or []: - if cls._field(content, "type") != "refusal": + for content in field(item, "content", []) or []: + if field(content, "type") != "refusal": continue - refusal = cls._field(content, "refusal") or cls._field(content, "text") + refusal = field(content, "refusal") or field(content, "text") if isinstance(refusal, str) and refusal: return refusal return None @@ -1630,7 +1623,7 @@ def _chat_status_is_error(cls, status: Any) -> bool: def _chat_response_error_detail(cls, response: Any) -> str | None: """提取兼容网关常见的顶层业务错误字段。""" for name in ("error", "msg", "message", "detail", "reason", "code", "error_code"): - value = cls._field(response, name) + value = field(response, name) if value is None or value == "": continue if name == "error": @@ -1654,14 +1647,14 @@ def _raise_for_chat_response_error( 字段仍必须显式失败。 """ detail = cls._chat_response_error_detail(response) - status = cls._field(response, "status") - if detail is not None and cls._field(response, "error") is not None: + status = field(response, "status") + if detail is not None and field(response, "error") is not None: raise RuntimeError(f"{provider} 返回错误: {detail}") if cls._chat_status_is_error(status): suffix = f": {detail}" if detail else "。" raise RuntimeError(f"{provider} 返回业务错误 (status={status}){suffix}") - choices = cls._field(response, "choices") + choices = field(response, "choices") if allow_empty_choices: if choices is None and detail is not None: raise RuntimeError(f"{provider} 返回错误: {detail}") @@ -1687,11 +1680,11 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C response, provider=f"{getattr(self, '_provider', 'OpenAI-compatible')} Chat Completions", ) - choices = self._field(response, "choices", []) or [] + choices = field(response, "choices", []) or [] finish_reason = ( - self._field(response, "status") + field(response, "status") if self._protocol == "responses" - else self._field(choices[0], "finish_reason") + else field(choices[0], "finish_reason") if choices else None ) @@ -1700,7 +1693,7 @@ def complete(self, request: CompletionRequest | None = None, **kwargs: Any) -> C tool_calls=self._extract_tool_calls(response), usage=self._extract_usage(response), finish_reason=finish_reason, - response_id=self._field(response, "id"), + response_id=field(response, "id"), refusal=self._extract_refusal(response), reasoning=self._extract_reasoning(response), raw=response, @@ -1730,11 +1723,11 @@ async def acomplete( response, provider=f"{getattr(self, '_provider', 'OpenAI-compatible')} Chat Completions", ) - choices = self._field(response, "choices", []) or [] + choices = field(response, "choices", []) or [] finish_reason = ( - self._field(response, "status") + field(response, "status") if self._protocol == "responses" - else self._field(choices[0], "finish_reason") + else field(choices[0], "finish_reason") if choices else None ) @@ -1743,7 +1736,7 @@ async def acomplete( tool_calls=self._extract_tool_calls(response), usage=self._extract_usage(response), finish_reason=finish_reason, - response_id=self._field(response, "id"), + response_id=field(response, "id"), refusal=self._extract_refusal(response), reasoning=self._extract_reasoning(response), raw=response, @@ -1753,23 +1746,23 @@ async def acomplete( def _chat_stream_events(cls, chunk: Any) -> list[StreamEvent]: raise_for_stream_error_event(chunk, "Chat Completions") cls._raise_for_chat_response_error(chunk, allow_empty_choices=True) - choices = cls._field(chunk, "choices", []) or [] + choices = field(chunk, "choices", []) or [] usage = cls._extract_usage(chunk) if not choices: return [StreamEvent(type="usage", usage=usage, raw=chunk)] if usage else [] choice = choices[0] - delta = cls._field(choice, "delta") - text = content_to_text(cls._field(delta, "content", "")) - reasoning = cls._field(delta, "reasoning_content") or cls._field(delta, "reasoning") or "" - refusal = cls._field(delta, "refusal") - tool_calls = cls._field(delta, "tool_calls", []) or [] - finish_reason = cls._field(choice, "finish_reason") + delta = field(choice, "delta") + text = content_to_text(field(delta, "content", "")) + reasoning = field(delta, "reasoning_content") or field(delta, "reasoning") or "" + refusal = field(delta, "refusal") + tool_calls = field(delta, "tool_calls", []) or [] + finish_reason = field(choice, "finish_reason") events: list[StreamEvent] = [] if isinstance(refusal, str) and refusal: events.append(StreamEvent(type="refusal_delta", refusal=refusal, raw=chunk)) - response_id = cls._field(chunk, "id") + response_id = field(chunk, "id") if text: events.append( StreamEvent( @@ -1790,16 +1783,16 @@ def _chat_stream_events(cls, chunk: Any) -> list[StreamEvent]: ) if tool_calls: for position, call in enumerate(tool_calls): - function = cls._field(call, "function") + function = field(call, "function") events.append( StreamEvent( type="tool_call_delta", tool_call={ - "id": cls._field(call, "id"), - "index": cls._field(call, "index", position), - "type": cls._field(call, "type", "function"), - "name": cls._field(function, "name"), - "arguments": cls._field(function, "arguments", ""), + "id": field(call, "id"), + "index": field(call, "index", position), + "type": field(call, "type", "function"), + "name": field(function, "name"), + "arguments": field(function, "arguments", ""), }, response_id=response_id, raw=chunk, @@ -1812,7 +1805,7 @@ def _chat_stream_events(cls, chunk: Any) -> list[StreamEvent]: type="finish", usage=usage, finish_reason=finish_reason, - response_id=cls._field(chunk, "id"), + response_id=field(chunk, "id"), raw=chunk, ) ) @@ -1833,15 +1826,15 @@ def _chat_stream_event(cls, chunk: Any) -> StreamEvent | None: def _response_tool_keys(cls, event: Any, item: Any = None) -> list[Any]: """返回 Responses 工具事件可用的稳定关联键。""" item = item if item is not None else event - item_id = cls._field(event, "item_id") + item_id = field(event, "item_id") if item_id is None: - item_id = cls._field(item, "id") - output_index = cls._field(event, "output_index") + item_id = field(item, "id") + output_index = field(event, "output_index") if output_index is None: - output_index = cls._field(item, "output_index") - call_id = cls._field(event, "call_id") + output_index = field(item, "output_index") + call_id = field(event, "call_id") if call_id is None: - call_id = cls._field(item, "call_id") + call_id = field(item, "call_id") keys: list[Any] = [] for prefix, value in ( ("item_id", item_id), @@ -1864,24 +1857,24 @@ def _remember_response_tool_metadata( metadata: dict[Any, dict[str, Any]], ) -> None: """记录 output_item.added 中后续 delta/done 事件需要的工具元数据。""" - item = cls._field(event, "item") or event - item_type = cls._field(item, "type") + item = field(event, "item") or event + item_type = field(item, "type") if item_type not in {"function_call", "custom_tool_call"}: return - item_id = cls._field(item, "id") or cls._field(event, "item_id") - call_id = cls._field(item, "call_id") or cls._field(event, "call_id") - output_index = cls._field(event, "output_index") + item_id = field(item, "id") or field(event, "item_id") + call_id = field(item, "call_id") or field(event, "call_id") + output_index = field(event, "output_index") if output_index is None: - output_index = cls._field(item, "output_index") + output_index = field(item, "output_index") values: dict[str, Any] = { "id": call_id or item_id, "call_id": call_id, "index": output_index, "type": item_type, - "name": cls._field(item, "name"), + "name": field(item, "name"), } for field_name in ("arguments", "input"): - value = cls._field(item, field_name) + value = field(item, field_name) if value is not None: values["arguments"] = normalize_tool_arguments(value) break @@ -1919,13 +1912,13 @@ def _responses_stream_event( error_provider: str = "Responses API", response_error_handler: Any | None = None, ) -> StreamEvent | None: - event_type = cls._field(event, "type", "") + event_type = field(event, "type", "") if event_type == "response.output_item.added": if tool_metadata is not None: cls._remember_response_tool_metadata(event, tool_metadata) return None if event_type in {"response.output_text.delta", "response.refusal.delta"}: - delta = cls._field(event, "delta") + delta = field(event, "delta") is_refusal = event_type.endswith("refusal.delta") return StreamEvent( type="refusal_delta" if is_refusal else "text_delta", @@ -1934,7 +1927,7 @@ def _responses_stream_event( raw=event, ) if event_type in {"response.reasoning_summary_text.delta", "response.reasoning_text.delta"}: - delta = cls._field(event, "delta") + delta = field(event, "delta") return StreamEvent( type="reasoning_delta", reasoning=delta if isinstance(delta, str) else "", raw=event ) @@ -1946,9 +1939,9 @@ def _responses_stream_event( "type", "custom_tool_call" if "custom_tool_call_input" in event_type else "function_call", ) - item_id = cls._field(event, "item_id") - call_id = cls._field(event, "call_id") or metadata.get("call_id") - output_index = cls._field(event, "output_index") + item_id = field(event, "item_id") + call_id = field(event, "call_id") or metadata.get("call_id") + output_index = field(event, "output_index") if output_index is None: output_index = metadata.get("index") return StreamEvent( @@ -1961,12 +1954,10 @@ def _responses_stream_event( # ``role=tool`` 时就会配不上。 "id": call_id or item_id or metadata.get("id"), "call_id": call_id, - "index": output_index - if output_index is not None - else cls._field(event, "index"), + "index": output_index if output_index is not None else field(event, "index"), "type": item_type, - "name": cls._field(event, "name") or metadata.get("name"), - "arguments": normalize_tool_arguments(cls._field(event, "delta", "")), + "name": field(event, "name") or metadata.get("name"), + "arguments": normalize_tool_arguments(field(event, "delta", "")), }, raw=event, ) @@ -1975,9 +1966,9 @@ def _responses_stream_event( "response.function_call_arguments.done", "response.custom_tool_call_input.done", }: - item = cls._field(event, "item") + item = field(event, "item") metadata = cls._response_tool_metadata(event, tool_metadata, item) - item_type = cls._field(item, "type") if item is not None else None + item_type = field(item, "type") if item is not None else None item_type = item_type or metadata.get("type") if ( item_type in {"function_call", "custom_tool_call"} @@ -1985,17 +1976,15 @@ def _responses_stream_event( or "custom_tool_call_input" in event_type ): call_id = ( - cls._field(item, "call_id") - or cls._field(event, "call_id") - or metadata.get("call_id") + field(item, "call_id") or field(event, "call_id") or metadata.get("call_id") ) - output_index = cls._field(event, "output_index") + output_index = field(event, "output_index") if output_index is None: output_index = metadata.get("index") argument_value: Any = None for source in (item, event): for field_name in ("arguments", "input"): - value = cls._field(source, field_name) + value = field(source, field_name) if value is not None: argument_value = value break @@ -2011,18 +2000,14 @@ def _responses_stream_event( # 回填 ``role=tool`` 时就会配不上。上面解析出的 ``call_id`` # 才是权威来源。 "id": call_id - or cls._field(item, "id") - or cls._field(event, "item_id") + or field(item, "id") + or field(event, "item_id") or metadata.get("id"), - "index": output_index - if output_index is not None - else cls._field(event, "index"), + "index": output_index if output_index is not None else field(event, "index"), "type": item_type if item_type in {"function_call", "custom_tool_call"} else "function_call", - "name": cls._field(item, "name") - or cls._field(event, "name") - or metadata.get("name"), + "name": field(item, "name") or field(event, "name") or metadata.get("name"), "arguments": normalize_tool_arguments(argument_value), } if call_id is not None: @@ -2040,9 +2025,9 @@ def _responses_stream_event( "response.failed", "response.cancelled", }: - response = cls._field(event, "response") or event + response = field(event, "response") or event usage = cls._extract_usage(response) - status = cls._field(response, "status") + status = field(response, "status") if status in {"failed", "incomplete", "cancelled"}: handler = response_error_handler or cls._raise_for_response_error handler(response) @@ -2052,7 +2037,7 @@ def _responses_stream_event( type="finish", usage=usage, finish_reason=status, - response_id=cls._field(response, "id"), + response_id=field(response, "id"), raw=event, ) return None @@ -2163,7 +2148,7 @@ def stream_events( ): if terminal_seen: continue - event_type = self._field(event, "type", "") + event_type = field(event, "type", "") converted = self._responses_stream_event(event, response_tool_metadata) if converted: if converted.type == "finish": @@ -2254,7 +2239,7 @@ async def astream_events( ): if terminal_seen: continue - event_type = self._field(event, "type", "") + event_type = field(event, "type", "") converted = self._responses_stream_event(event, response_tool_metadata) if converted: if converted.type == "finish": @@ -2402,30 +2387,30 @@ async def async_parse( def _error_message(cls, error: Any, default: str) -> str: if isinstance(error, str) and error: return error - message = cls._field(error, "message") + message = field(error, "message") if message: return str(message) - code = cls._field(error, "code") + code = field(error, "code") if code: return f"{code}" return default @classmethod def _raise_for_response_error(cls, response: Any) -> None: - error = cls._field(response, "error") + error = field(response, "error") if error: # 与 _raise_for_chat_response_error 对齐:服务端消息常回显请求头或 # URL,异常文本会进日志与终端,是本项目凭证最容易泄漏的出口。 message = redact_sensitive_text(cls._error_message(error, "未知错误。")) raise RuntimeError(f"Responses API 返回错误: {message}") - status = cls._field(response, "status") + status = field(response, "status") if status not in {"failed", "incomplete", "cancelled"}: return if status == "incomplete": - details = cls._field(response, "incomplete_details") - reason = cls._field(details, "reason") + details = field(response, "incomplete_details") + reason = field(details, "reason") detail = f"原因: {reason}" if reason else "未提供原因。" else: detail = "未提供原因。" @@ -2433,9 +2418,9 @@ def _raise_for_response_error(cls, response: Any) -> None: @classmethod def _stream_delta(cls, event: Any) -> str: - event_type = cls._field(event, "type", "") + event_type = field(event, "type", "") if event_type in {"response.output_text.delta", "response.refusal.delta"}: - delta = cls._field(event, "delta") + delta = field(event, "delta") return delta if isinstance(delta, str) else "" if event_type in {"error", "response.error"}: raise_for_stream_error_event(event, "Responses API") @@ -2445,7 +2430,7 @@ def _stream_delta(cls, event: Any) -> str: "response.incomplete", "response.cancelled", }: - response = cls._field(event, "response") or event + response = field(event, "response") or event cls._raise_for_response_error(response) if event_type != "response.completed": raise RuntimeError(f"Responses API 收到终止事件: {event_type}。") @@ -2458,9 +2443,9 @@ def _invoke_responses(self, request: dict[str, Any]) -> Iterator[str]: lambda: self._get_client().responses.create(**request), lambda event: raise_for_stream_error_event(event, self._provider), ): - event_type = self._field(event, "type", "") + event_type = field(event, "type", "") if event_type == "response.refusal.delta": - refusal = self._field(event, "delta") + refusal = field(event, "delta") if refusal: raise RuntimeError(f"{self._provider} Responses 返回拒答: {refusal}") delta = self._stream_delta(event) @@ -2489,9 +2474,9 @@ async def _ainvoke_responses(self, request: dict[str, Any]) -> AsyncIterator[str lambda: self._get_aclient().responses.create(**request), lambda event: raise_for_stream_error_event(event, self._provider), ): - event_type = self._field(event, "type", "") + event_type = field(event, "type", "") if event_type == "response.refusal.delta": - refusal = self._field(event, "delta") + refusal = field(event, "delta") if refusal: raise RuntimeError(f"{self._provider} Responses 返回拒答: {refusal}") delta = self._stream_delta(event) @@ -2620,10 +2605,10 @@ def invoke( refusal = self._extract_refusal(response) if refusal: raise RuntimeError(f"{self._provider} Chat Completions 返回拒答: {refusal}") - choices = self._field(response, "choices", []) or [] + choices = field(response, "choices", []) or [] if choices: - message = self._field(choices[0], "message") - content = self._field(message, "content") + message = field(choices[0], "message") + content = field(message, "content") if content: yield content_to_text(content) @@ -2751,10 +2736,10 @@ async def ainvoke( refusal = self._extract_refusal(response) if refusal: raise RuntimeError(f"{self._provider} Chat Completions 返回拒答: {refusal}") - choices = self._field(response, "choices", []) or [] + choices = field(response, "choices", []) or [] if choices: - message = self._field(choices[0], "message") - content = self._field(message, "content") + message = field(choices[0], "message") + content = field(message, "content") if content: yield content_to_text(content) @@ -3182,7 +3167,7 @@ def count_input_tokens(self, request: CompletionRequest | None = None, **kwargs: kwargs=params, operation="OpenAI Responses 输入 token 统计", ) - value = self._field(response, "input_tokens") + value = field(response, "input_tokens") if value is None: raise RuntimeError("OpenAI token count 响应缺少 input_tokens。") return int(value) @@ -4368,7 +4353,7 @@ async def async_count_input_tokens( kwargs=params, operation="OpenAI Responses 输入 token 统计", ) - value = self._field(response, "input_tokens") + value = field(response, "input_tokens") if value is None: raise RuntimeError("OpenAI token count 响应缺少 input_tokens。") return int(value) diff --git a/src/providers/siliconflow_rerank.py b/src/providers/siliconflow_rerank.py index 7e0056d..533b672 100644 --- a/src/providers/siliconflow_rerank.py +++ b/src/providers/siliconflow_rerank.py @@ -18,8 +18,15 @@ class SiliconflowRerankProvider(RerankModel): - """ - SiliconFlow Rerank模型提供商。 + """SiliconFlow Rerank Provider(HTTP JSON)。 + + 本类与 ``jina.py`` 是近似副本(行级相似度约 0.78,7 个方法同名, + ``_get_headers`` 逐字节相同)。两者**有意保持独立实现**,因为存在 4 处真实 + 语义差异:``_OPTION_KEYS``、``_base_url`` 来源(本类走 settings,Jina 硬编码)、 + 构造期 base_url 校验(本类执行,Jina 不执行)、以及 ``_parse_response`` 里 + ``results`` 类型校验的时机(本类在 Mapping 循环之后)。 + **修改任一侧的校验逻辑、payload 构造或响应解析时,必须同步另一侧。** + 已注入 CSE 性能传感器与 tenacity 重试机制。 """ diff --git a/src/providers/volcengine.py b/src/providers/volcengine.py index ec86f02..e5828a1 100644 --- a/src/providers/volcengine.py +++ b/src/providers/volcengine.py @@ -313,18 +313,6 @@ class VolcengineProvider(LargeLanguageModel, TextEmbeddingModel): "timeout", } ) - _ARK_CLASSIFICATION_KEYS = frozenset( - { - "query", - "model", - "labels", - "user", - "extra_headers", - "extra_query", - "extra_body", - "timeout", - } - ) _ARK_CONTENT_GENERATION_CREATE_KEYS = frozenset( { "model", @@ -732,23 +720,10 @@ async def _resolve_async_result(result: Any) -> Any: return await result return result - @staticmethod - def _merge_options(request: dict[str, Any], options: dict[str, Any]) -> None: - reserved = {"model", "messages", "input", "stream", "instructions", "tools"} - overlap = sorted(reserved.intersection(options)) - if overlap: - raise ValueError(f"Provider options 不允许覆盖请求字段: {', '.join(overlap)}") - for key, value in options.items(): - request.setdefault(key, value) - - @staticmethod - def _validate_model_options( - options: Mapping[str, Any], - allowed: frozenset[str], - endpoint: str, - ) -> None: - unknown = {key: value for key, value in options.items() if key not in allowed} - reject_unsupported_kwargs(endpoint, unknown) + # 以下 4 个 helper 是 OpenAICompatibleProvider 的逐字节副本,改为委托。 + # 保留本类方法名是因为调用点很多;实现只留一份,避免两边漂移。 + _merge_options = staticmethod(OpenAICompatibleProvider._merge_options) + _validate_model_options = staticmethod(OpenAICompatibleProvider._validate_model_options) def _request_options(self) -> dict[str, Any]: """返回不会误传给 Ark 请求的模型级选项。""" @@ -765,35 +740,8 @@ def _request_options(self) -> dict[str, Any]: } } - @staticmethod - def _validated_extra_body( - value: Any, - endpoint: str, - reserved: set[str], - ) -> dict[str, Any]: - if value is None: - return {} - if not isinstance(value, Mapping): - raise ValueError(f"{endpoint} extra_body 必须是对象。") - value = validate_secret_free_payload(value, endpoint, "extra_body") - overlap = sorted(set(value).intersection(reserved)) - if overlap: - raise ValueError(f"{endpoint} extra_body 不允许覆盖请求字段: {', '.join(overlap)}") - return dict(value) - - @staticmethod - def _merge_extra_body( - configured: Mapping[str, Any] | None, - extensions: Mapping[str, Any] | None, - endpoint: str, - ) -> dict[str, Any]: - """合并模型级扩展,拒绝同一字段的静默覆盖。""" - configured_values = dict(configured or {}) - extension_values = dict(extensions or {}) - overlap = sorted(set(configured_values).intersection(extension_values)) - if overlap: - raise ValueError(f"{endpoint} 模型 options 的扩展字段重复: {', '.join(overlap)}") - return {**configured_values, **extension_values} + _validated_extra_body = staticmethod(OpenAICompatibleProvider._validated_extra_body) + _merge_extra_body = staticmethod(OpenAICompatibleProvider._merge_extra_body) @staticmethod def _convert_responses_format(response_format: dict[str, Any]) -> dict[str, Any]: diff --git a/src/retrieval/vdb/faiss_store.py b/src/retrieval/vdb/faiss_store.py index 9ab83fb..f14d67f 100644 --- a/src/retrieval/vdb/faiss_store.py +++ b/src/retrieval/vdb/faiss_store.py @@ -32,8 +32,6 @@ class FaissStore(VectorStoreBase): def __init__(self, file_path: str | None = None): - import asyncio - self.file_path = file_path self.documents: list[dict[str, Any]] = [] self.embeddings: np.ndarray | None = None @@ -41,7 +39,6 @@ def __init__(self, file_path: str | None = None): self._tokenized_docs_cache: list[list[str]] = [] self.bm25_index: BM25Okapi | None = None self.faiss_index: faiss.Index | None = None - self.lock = asyncio.Lock() if self.file_path and os.path.exists(self.file_path): self.load(self.file_path) diff --git a/src/utils/config.py b/src/utils/config.py index 9bebb43..7c53bb3 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -729,104 +729,3 @@ def _redacted_settings_error(exc: ValidationError) -> str: # 导出 get_settings 函数,供其他模块在需要时调用 # 这样可以确保在测试中能够灵活地替换或模拟配置 # settings = get_settings() # 移除直接导出 settings 实例 - -# ================================================================= -# 4. 向后兼容层 (BACKWARD COMPATIBILITY LAYER) -# ================================================================= -# 目标: 最小化对现有代码的侵入性。 -# 策略: 保持旧的配置变量,但使其从新的settings实例派生。 -# 后续重构中,应逐步淘汰这些变量,直接使用 `get_settings()` 对象。 - - -def get_backward_compatible_configs() -> dict[str, Any]: - """ - 获取向后兼容的配置字典。 - """ - current_settings = get_settings() - - # --- 路径与环境配置 --- - KB_PATH = Path(current_settings.knowledge_base_path) - PKL_PATH = Path(current_settings.pkl_path) - LOG_PATH = Path(current_settings.log_path) - LOG_RETENTION_DAYS = current_settings.log_retention_days - CACHE_PATH = Path(current_settings.cache_path) - - # --- 知识库构建配置 --- - KB_CONFIG = { - "replace_consecutive_whitespace": current_settings.kb_replace_whitespace, - "remove_extra_spaces": current_settings.kb_remove_spaces, - "remove_urls_and_emails": current_settings.kb_remove_urls, - "text_splitter_separators": current_settings.kb_splitter_separators, - "chunk_size": current_settings.kb_chunk_size, - "chunk_overlap": current_settings.kb_chunk_overlap, - "child_chunk_size": current_settings.kb_child_chunk_size, - "child_chunk_overlap": current_settings.kb_child_chunk_overlap, - "use_qa_segmentation": current_settings.kb_use_qa_segmentation, - "embedding_configurations": current_settings.embedding_configurations, - "active_embedding_configuration": current_settings.default_embedding_provider, - "embedding_batch_size": current_settings.kb_embedding_batch_size, - "kb_dir": str(KB_PATH), - "output_file": str(PKL_PATH), - } - - # --- 聊天机器人配置 (可动态修改) --- - CHAT_CONFIG = { - "active_llm_configuration": current_settings.default_llm_provider, - "retrieval_method": current_settings.chat_retrieval_method, - "vector_weight": current_settings.chat_vector_weight, - "keyword_weight": current_settings.chat_keyword_weight, - "hybrid_fusion_strategy": current_settings.hybrid_fusion_strategy, - "retrieval_candidate_multiplier": current_settings.retrieval_candidate_multiplier, - "rerank_enabled": current_settings.chat_rerank_enabled, - "top_k": current_settings.chat_top_k, - "score_threshold": current_settings.chat_score_threshold, - "active_rerank_configuration": current_settings.default_rerank_provider, - "rerank_configurations": current_settings.rerank_configurations, - "llm_configurations": current_settings.llm_configurations, - } - - # --- API密钥与URL配置 --- - API_CONFIG = { - "ANTHROPIC_API_KEY": current_settings.anthropic_api_key, - "GOOGLE_API_KEY": current_settings.google_api_key, - "GEMINI_API_KEY": current_settings.gemini_api_key, - "SILICONFLOW_API_KEY": current_settings.siliconflow_api_key, - "OPENAI_API_KEY": current_settings.openai_api_key, - "QWEN_API_KEY": current_settings.qwen_api_key, - "ARK_API_KEY": current_settings.ark_api_key, - "VOLC_ACCESS_KEY": current_settings.volc_access_key, - "VOLC_SECRET_KEY": current_settings.volc_secret_key, - "JINA_API_KEY": current_settings.jina_api_key, - "DEEPSEEK_API_KEY": current_settings.deepseek_api_key, - "GROK_API_KEY": current_settings.grok_api_key, - "LM_STUDIO_API_KEY": current_settings.lm_studio_api_key, - "OPENAI_API_BASE": str(current_settings.openai_api_base), - "SILICONFLOW_BASE_URL": str(current_settings.siliconflow_base_url), - "QWEN_BASE_URL": str(current_settings.qwen_base_url), - "DEEPSEEK_BASE_URL": str(current_settings.deepseek_base_url), - "OLLAMA_BASE_URL": str(current_settings.ollama_base_url), - "LM_STUDIO_BASE_URL": str(current_settings.lm_studio_base_url), - "VOLC_BASE_URL": str(current_settings.volc_base_url), - "GROK_BASE_URL": str(current_settings.grok_base_url), - } - - return { - "KB_PATH": KB_PATH, - "PKL_PATH": PKL_PATH, - "LOG_PATH": LOG_PATH, - "LOG_RETENTION_DAYS": LOG_RETENTION_DAYS, - "CACHE_PATH": CACHE_PATH, - "KB_CONFIG": KB_CONFIG, - "CHAT_CONFIG": CHAT_CONFIG, - "API_CONFIG": API_CONFIG, - "EMBEDDING_CONFIGS": current_settings.embedding_configurations, - "RERANK_CONFIGS": current_settings.rerank_configurations, - "LLM_CONFIGS": current_settings.llm_configurations, - } - - -# --- 为了解决循环导入问题,将模型配置的导出移到最后 --- -# 这些变量现在通过 get_backward_compatible_configs() 函数提供 -# EMBEDDING_CONFIGS = settings.embedding_configurations -# RERANK_CONFIGS = settings.rerank_configurations -# LLM_CONFIGS = settings.llm_configurations diff --git a/uv.lock b/uv.lock index 971d95b..5466a75 100644 --- a/uv.lock +++ b/uv.lock @@ -9,146 +9,6 @@ resolution-markers = [ "python_full_version < '3.12'", ] -[[package]] -name = "aiohappyeyeballs" -version = "2.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, -] - -[[package]] -name = "aiohttp" -version = "3.14.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohappyeyeballs" }, - { name = "aiosignal" }, - { name = "attrs" }, - { name = "frozenlist" }, - { name = "multidict" }, - { name = "propcache" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "yarl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, - { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, - { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, - { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, - { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, - { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, - { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, - { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, - { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, - { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, - { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, - { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, - { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, - { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, - { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, - { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, - { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, - { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, - { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, - { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, - { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, - { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, - { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, - { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, - { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, - { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, - { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, - { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, - { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, - { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, - { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, - { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, - { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, - { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, - { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, - { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, - { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, - { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, - { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, - { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, - { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, - { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, - { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, - { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, - { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, - { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, - { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, - { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, - { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, - { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, - { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, - { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, - { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, - { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, - { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, - { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, - { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, - { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, - { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, - { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, - { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, - { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, - { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, - { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, - { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, - { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, - { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, - { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, - { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, - { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, - { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, - { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, - { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, - { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, - { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, - { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, - { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, - { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, - { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, - { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, -] - -[[package]] -name = "aiosignal" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "frozenlist" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, -] - [[package]] name = "altgraph" version = "0.17.5" @@ -262,15 +122,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/45/c7cd8d36d3b506bbd02db5066fae3340284781168f0d08dac25deef5f69d/ast_serialize-0.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:74473258a5c55855d5306c864a5c799fbff03a0f0ea1197346b2b5cc5b4ea48a", size = 1128237, upload-time = "2026-09-02T15:50:43.496Z" }, ] -[[package]] -name = "attrs" -version = "26.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, -] - [[package]] name = "bandit" version = "1.9.4" @@ -654,111 +505,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/13/0a021b9df16963f839a3f325657656b70f23b5a6dbeb422eaa187d0121b3/faiss_cpu-1.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:37170d5e9ead4b6bfd9c314afc39e17e92064068a0c5a4063dd3f39568c2667e", size = 16535739, upload-time = "2026-08-03T17:50:06.714Z" }, ] -[[package]] -name = "frozenlist" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, - { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, - { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, - { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, - { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, - { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, - { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, - { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, - { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, - { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, - { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, - { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, - { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, - { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, - { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, - { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, - { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, - { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, - { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, - { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, - { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, - { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, - { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, - { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, - { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, - { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, - { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, - { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, - { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, - { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, - { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, - { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, - { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, - { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, - { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, - { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, - { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, - { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, - { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, - { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, - { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, - { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, - { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, - { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, - { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, - { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, - { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, - { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, - { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, - { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, - { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, - { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, - { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, - { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, - { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, - { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, - { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, - { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, - { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, - { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, - { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, - { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, - { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, -] - [[package]] name = "google-auth" version = "2.57.1" @@ -798,69 +544,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/96/0120d214958cb54f2b9c48da9f564a0e6eae269e9dd702415fb1d0551cc7/google_genai-2.22.0-py3-none-any.whl", hash = "sha256:c514001c45470cc0a942440ae1b8215445d12bfb6c373aac94637127e1f74ec6", size = 1088792, upload-time = "2026-09-02T18:06:00.629Z" }, ] -[[package]] -name = "greenlet" -version = "3.5.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/d8/7cc97c142388aef03f622e001c572c4f84e9252a439549d483f555771970/greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c", size = 207585, upload-time = "2026-08-10T15:09:36.136Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/a3/07297917485ee2ca85bc3c8dc6ed85ad3fffcf424047fba62671dba68e97/greenlet-3.5.5-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:be63afcbbccfad3dd95a1ba12ada84dab2ef32031973d80b5b92df67fa763a61", size = 294165, upload-time = "2026-08-10T13:25:17.987Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/6f732f9314cda54c5fd48a7620c7160f4f286967e8045ad94b9d66ce80b7/greenlet-3.5.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a268024ce2d7d2b04694bf1594058981a9fa663d1df4b762dee499211ed7c1c", size = 613610, upload-time = "2026-08-10T14:14:33.829Z" }, - { url = "https://files.pythonhosted.org/packages/d8/c0/b27589e25d220289edcd4d582b2b17b83058d1a56d53d971b6ea1a34f10d/greenlet-3.5.5-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35cbb8bf55ace57fbccb4fb8622c4521713acd8691e77f4696d416ea7ca527da", size = 625481, upload-time = "2026-08-10T14:27:23.647Z" }, - { url = "https://files.pythonhosted.org/packages/51/2d/f2c928218ac52f26d7a2c188c171d1b7e728b23782cb3347e7b4fce1493a/greenlet-3.5.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cc6df89ec5302337adc9cf096221cbed2510fd444b0e0f1586cf0470740864", size = 624562, upload-time = "2026-08-10T13:40:48.064Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4a/92fc51d5d35912f4f06eec037ba347985defd0be47463a010a325634d9d2/greenlet-3.5.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d9b454c5fc48aeaa7c4337813dbf513a6870468e426438a04d922c6d0fe63db", size = 1584909, upload-time = "2026-08-10T14:15:04.343Z" }, - { url = "https://files.pythonhosted.org/packages/ac/58/ed98b80ac5738c149a5258544843c45601ade1fd70f61740cdaead6351b3/greenlet-3.5.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03551ed792cb1b4fc0277a0c60dfd8c343894a0ba06fe60dcd22f568b433da39", size = 1651184, upload-time = "2026-08-10T13:40:28.879Z" }, - { url = "https://files.pythonhosted.org/packages/d8/be/b582ceb80cefdf9d8da34078714e4b12b3d16f509dee0f65e40a5cc8fc7d/greenlet-3.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:ab3df3dffb58bf70564e93a5cec7941e4d9faa5a36cc4234a10d3131afe04f53", size = 323280, upload-time = "2026-08-10T13:26:07.495Z" }, - { url = "https://files.pythonhosted.org/packages/4d/18/5313c4c58598c38b0373c013e4ff2b3e6d258aaaa338f373335ebecdaddd/greenlet-3.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:2b70a766135540c472ac1393d57c2e1b4a2eb85bf526a1e41e6d096173a8cee5", size = 307785, upload-time = "2026-08-10T13:28:34.874Z" }, - { url = "https://files.pythonhosted.org/packages/2e/7e/9ecd0285e3153532ae07aeb88063c43c72b4221cf0d4d123b02f3682e3ff/greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380", size = 295809, upload-time = "2026-08-10T13:25:34.023Z" }, - { url = "https://files.pythonhosted.org/packages/35/73/60e4bbcc89252037b18087f2ec16405d5b2d5be42dde191bbf3667e96102/greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053", size = 611910, upload-time = "2026-08-10T14:14:35.18Z" }, - { url = "https://files.pythonhosted.org/packages/a4/17/cd5134be659cd4a443e7a61ae670dabec165a814c51162916d637b6dd38e/greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95", size = 624198, upload-time = "2026-08-10T14:27:25.229Z" }, - { url = "https://files.pythonhosted.org/packages/78/ac/5c5b959999b6f09c3026b5dfe171575bc3121c5236ce74f495096f25b203/greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d", size = 621439, upload-time = "2026-08-10T13:40:49.391Z" }, - { url = "https://files.pythonhosted.org/packages/c8/8b/6acf112ed8aee499f25b4d6949820fb02ac950ff9c1f3d793bd5be0599f2/greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b", size = 1581342, upload-time = "2026-08-10T14:15:05.653Z" }, - { url = "https://files.pythonhosted.org/packages/b8/d7/734e5f198888876b42d7616ff6644c075baf6b8a2412deadd6b0e1b8b20c/greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2", size = 1645744, upload-time = "2026-08-10T13:40:30.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/30/1f42b88dc587b5899ee50616ad56ee40cafaf225df4fb829f10183c62a5c/greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18", size = 324171, upload-time = "2026-08-10T13:28:44.472Z" }, - { url = "https://files.pythonhosted.org/packages/76/e5/4dee4d8d2e603fe5fdd7b444e63219f7b9bd852c60c6214511c7157cbe88/greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52", size = 308362, upload-time = "2026-08-10T13:26:46.839Z" }, - { url = "https://files.pythonhosted.org/packages/fb/3d/8cef5f724ec0d4add2af8961d504535ec60c3cca9e464f6d03bdba29d85b/greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa", size = 294730, upload-time = "2026-08-10T13:27:51.206Z" }, - { url = "https://files.pythonhosted.org/packages/88/4b/8e7aa3f514273aecff30a16ab1bac09ff54cfc7e6860fdd8058c37ff2499/greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed", size = 614536, upload-time = "2026-08-10T14:14:36.589Z" }, - { url = "https://files.pythonhosted.org/packages/85/48/4e95e9dd5a8a397dc6a6345dd7f1935113d0fca4f85e89d3976da9cd988d/greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef", size = 626924, upload-time = "2026-08-10T14:27:27.048Z" }, - { url = "https://files.pythonhosted.org/packages/89/5d/398a1c71fa7a277deeb376c999979de6786f08fc2d5747a0b9d6e11738dd/greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0", size = 623906, upload-time = "2026-08-10T13:40:50.501Z" }, - { url = "https://files.pythonhosted.org/packages/04/1b/745450fc5ea9e0cb17d840d248f284db3363de736d362c7d2d883e3eadba/greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537", size = 1581430, upload-time = "2026-08-10T14:15:06.853Z" }, - { url = "https://files.pythonhosted.org/packages/d4/29/d51b296e3191bb15d3d81ec375af1909e4466c0f395d744ed475801798a9/greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e", size = 1645684, upload-time = "2026-08-10T13:40:32.133Z" }, - { url = "https://files.pythonhosted.org/packages/12/63/369f1a1625e64e9e31df3963c6044056e3fdfa3fa3fdba3c54ffefa6e987/greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd", size = 324075, upload-time = "2026-08-10T13:26:58.974Z" }, - { url = "https://files.pythonhosted.org/packages/45/78/649cb5c09d4d81f6dd1444e75474a7206784743283a21d24171562ac4899/greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc", size = 308260, upload-time = "2026-08-10T13:27:50.795Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8c/080e881fa2be95ff1ddbd6994b2bab3b1a78df3b3fcab39306011764fcc7/greenlet-3.5.5-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d4a389a852e392a6366058651a20fa5ba40d979865aa81bea2ccbdc44805070d", size = 295309, upload-time = "2026-08-10T13:26:03.032Z" }, - { url = "https://files.pythonhosted.org/packages/25/cc/0ac614e6586c0e42d4cc281a5819150f4f43685744a4c5ff77139286409d/greenlet-3.5.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70b157cd319873e8b544ddc2de158f55bbd0a9b0218c8ce9332039801518e328", size = 661185, upload-time = "2026-08-10T14:14:37.867Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b9/6808725354be8ad305dfe5172377664fc9642d4fc043be246b3314cf4482/greenlet-3.5.5-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8bdfd1424abcf26832961e766570cae79efdb9599d709088c9cb6ef82b194926", size = 673419, upload-time = "2026-08-10T14:27:28.652Z" }, - { url = "https://files.pythonhosted.org/packages/42/2e/40c509967da7f254680826a2fa0dd22138ec79946c70b97542d74cde8b43/greenlet-3.5.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:182de51c6b572a705f2fafaab2e783bcf7d2760940229dfe73086cbae037af3e", size = 670822, upload-time = "2026-08-10T13:40:51.833Z" }, - { url = "https://files.pythonhosted.org/packages/2d/22/c3c2eee4a8fe191d6d1d183086c56133d646024e3d70bfd414829f64560b/greenlet-3.5.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8fec3f165dfe332e490c3247c0f6c23b0bfc45f06496ad7f00ddb00e3d35e4dc", size = 1628469, upload-time = "2026-08-10T14:15:08.11Z" }, - { url = "https://files.pythonhosted.org/packages/f7/87/25babd09b94cb1f03e71db815fde463f0262e40cfbd953d58a8d77311351/greenlet-3.5.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c6ce25fee6cabc8bf22cb8b52e642cbb821be5b9aec8094d07ff03378141b8e9", size = 1691952, upload-time = "2026-08-10T13:40:33.502Z" }, - { url = "https://files.pythonhosted.org/packages/2e/3d/5cc9701117ea4dc0eb7bf1f4f9b7888a6e2e5277ddfae095805ace50f2b6/greenlet-3.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:7dffc5c859fe6059974df1e37d7923d654a83e2ae18fdd616994270e001115e1", size = 327458, upload-time = "2026-08-10T13:27:02.868Z" }, - { url = "https://files.pythonhosted.org/packages/a7/6b/594fa2de7fae7629168a404a4305d7d7e31a5742c50a801b1839543cb93d/greenlet-3.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:5e2afcfc4d4305dd715809b03da5cbe437c8984f61d8917751eb5fe4aefa3e07", size = 311146, upload-time = "2026-08-10T13:27:25.046Z" }, - { url = "https://files.pythonhosted.org/packages/24/e0/50cd600b469e5734c72709b6b1838b6bc63f307b573c772c3132d6ecfe92/greenlet-3.5.5-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:0e5a7de979d764aea1f5b6e95cf92b5b37741b9823702041f34b126e7f690277", size = 305471, upload-time = "2026-08-10T13:26:20.568Z" }, - { url = "https://files.pythonhosted.org/packages/75/a3/77acd66dfc6387b5219b2080806c0cabb73c10eb1bb44b413c40a62015ba/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fef01bd457f11fc158b130ca0027a3c365693280e8e231b65bdaf57999f39f5b", size = 672470, upload-time = "2026-08-10T14:14:39.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/71/0d178142dca3ec19f46fb2212ae73d30ad53b9d548dc64804086033a7089/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5173a72310725a74afc82c164f0e52cb8ad0de62f2bb623f24f6c0cc07d80272", size = 679973, upload-time = "2026-08-10T14:27:30.072Z" }, - { url = "https://files.pythonhosted.org/packages/6e/31/46eb8567302eaf787abf88d09df014e14ae3baf460af1b8b0efdbd3efcd5/greenlet-3.5.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44f08341873200ba8a60a8bc14ace3d91f1754f7fa7bc66157714a8cd420a476", size = 676634, upload-time = "2026-08-10T13:40:53.004Z" }, - { url = "https://files.pythonhosted.org/packages/a3/e9/b88bbf5b29970cb84172dc2c32aa3e5e579ceb94c808e81c826454138850/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d246c0db9a2513cd45f019ba178ea4d4d4705bd210ee465e2c15d76a1ab13874", size = 1637320, upload-time = "2026-08-10T14:15:09.317Z" }, - { url = "https://files.pythonhosted.org/packages/6d/8c/7631ed29cc6f0392f11830076e172ce4885e70b0bc2c1bce1731176d4b4e/greenlet-3.5.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:72507285b5caa1d17904a3f7c322ca780823a54170a0e04ec3f37bcc60d4db71", size = 1697412, upload-time = "2026-08-10T13:40:34.924Z" }, - { url = "https://files.pythonhosted.org/packages/da/0f/f7dd935f9c4cb1be49098770587f54d8a78518e55c89bce86c4fb4109057/greenlet-3.5.5-cp314-cp314t-win_amd64.whl", hash = "sha256:7805655781fb8f28a55d05fe57ed61f5f10f1892fb587673e3bb5264f28041f0", size = 331514, upload-time = "2026-08-10T13:29:20.611Z" }, - { url = "https://files.pythonhosted.org/packages/b7/e5/681b01f8fbc1b55232822f99e8f8afeb78a55a7c76a7bf9dbdc7ccb03a6d/greenlet-3.5.5-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:c0db80fcd5b8aece93f66c64f78a786bbb6b96c5fe63ef5a5a4581ecf8bab206", size = 295975, upload-time = "2026-08-10T13:28:45.985Z" }, - { url = "https://files.pythonhosted.org/packages/11/f2/69b488cd9e7267bf4b0fe8cdebf25d8d6df680d21bdf41150d23e23d6652/greenlet-3.5.5-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b241c32f912ada659808d68e308c568baf577eebf757d15471472de0c18cfad", size = 666823, upload-time = "2026-08-10T14:14:40.222Z" }, - { url = "https://files.pythonhosted.org/packages/84/d4/d5bc2fdebbdda0c94555925ba79948b8395d75a7f6a36cc85dce5bab9f11/greenlet-3.5.5-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ef6a08349401d8eaf3cb12688ac8557de95788556b8631ef17555a4a173022c0", size = 677613, upload-time = "2026-08-10T14:27:31.543Z" }, - { url = "https://files.pythonhosted.org/packages/bd/93/542d8a3a90f3b35c6ad8bf7e56a03010287f2cafa289a5b7985b5207db39/greenlet-3.5.5-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2e3d061b8e13aec2f0441689b3c71b244a20e5d274a52cb0f7e31bd1d139552", size = 675930, upload-time = "2026-08-10T13:40:54.205Z" }, - { url = "https://files.pythonhosted.org/packages/52/b5/89c9f2e8460d71101037d47a1feed11928615a5edd42370be290e0657eeb/greenlet-3.5.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9ab5f5b93655e77fe0d6c2dfd22b5eac751bb1f876d8ec21761b7c1fb9266007", size = 1633878, upload-time = "2026-08-10T14:15:10.693Z" }, - { url = "https://files.pythonhosted.org/packages/b8/60/297de93f3b02ac78a5e04d32bb8bbe3080f4a73d8ed95016561463b70618/greenlet-3.5.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f0e5a21bd4452a88cf032fc43c4a5b307ab1380eacb63b5988f9c0317885e773", size = 1696597, upload-time = "2026-08-10T13:40:36.252Z" }, - { url = "https://files.pythonhosted.org/packages/18/25/54c6eaff4f337fb670215e89eb2d00d9499487b658e709d4b477be4a342e/greenlet-3.5.5-cp315-cp315-win_amd64.whl", hash = "sha256:469dbb0a78625642f4a626cfd0c6e8bccc0385b5e49189b6308bbe849ec88a8e", size = 327700, upload-time = "2026-08-10T13:28:06.752Z" }, - { url = "https://files.pythonhosted.org/packages/67/67/857e88a36301caa0e029870132c2478bd55d896630321432afab03a3115f/greenlet-3.5.5-cp315-cp315-win_arm64.whl", hash = "sha256:2d57406c3efd32d7a81e17a674314e8bd00792cdab49ea3228a49aa1bfb2e769", size = 311750, upload-time = "2026-08-10T13:34:08.815Z" }, - { url = "https://files.pythonhosted.org/packages/10/e2/3144c0a116067ac1e30457b0139a94d60d1d36a86e015de68e9ac87cb3bc/greenlet-3.5.5-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:68184dfcf50ccaa8e864770fe0633a7e27250ea9329f8192ef47ee9ecfd78e1c", size = 306387, upload-time = "2026-08-10T13:27:00.897Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a1/cb4223a7e9b9f43b8807e8eb212358bfe2dfaa174a9ea2889eb1714dcba2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ec0dc0e59dc9c61af5c47348365ccbbd7addfafe0a93b00336ff3da2907bdc6", size = 676472, upload-time = "2026-08-10T14:14:41.417Z" }, - { url = "https://files.pythonhosted.org/packages/9e/cd/a154b4498e5d8f12ada291cfb3b8d596eadde2177f5bf09a9be699d2a446/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e604f58e35833fc46ef20302bcb314dddbfd3fcf33a4f936216d51dd678d63ae", size = 684238, upload-time = "2026-08-10T14:27:32.946Z" }, - { url = "https://files.pythonhosted.org/packages/bf/bb/b0031d260c2968a3c87deebc51d80c64e499377f993aafe06ee3b7488cc2/greenlet-3.5.5-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40239b5384f96da3963585cc6d7eaa9b56f8ae67e8d92cc82dd9e202fc847de3", size = 681246, upload-time = "2026-08-10T13:40:55.402Z" }, - { url = "https://files.pythonhosted.org/packages/9a/07/da554b71ab88e649da146e1065d86a48a5c5d92e50ab74ef41b504aa7f56/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:a1eaccf5c3a1d3e46dead602c72e6836731e8e245c9de6a27764567b6b62d4c0", size = 1642735, upload-time = "2026-08-10T14:15:11.92Z" }, - { url = "https://files.pythonhosted.org/packages/78/76/26a3782a051677668af9d92beaa47cd87ba9dd5072f762961144a03dd4c6/greenlet-3.5.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:19e4e026fe20691f333b8eb1a3bc9625eceba8c3f9d62ec5a6f8581afbc6b5a5", size = 1700925, upload-time = "2026-08-10T13:40:37.656Z" }, - { url = "https://files.pythonhosted.org/packages/28/d9/fe7baf4190c2ae71f267efb9de21b3172bb35bc0ed1ef53dd6027d658e33/greenlet-3.5.5-cp315-cp315t-win_amd64.whl", hash = "sha256:712aee154f648bde84634654bb38bb78c69ac640c37a45c9effed800735049d8", size = 331829, upload-time = "2026-08-10T13:26:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/df/af/419a4e383bd600858a9b67e9b280a60fdc383ee3f2fe5b6c0c1ef04e74d1/greenlet-3.5.5-cp315-cp315t-win_arm64.whl", hash = "sha256:7f049911ee81a16a03c33d5450d8d5867d27f596ca5fb201b86f4524e874468b", size = 315093, upload-time = "2026-08-10T13:29:34.949Z" }, -] - [[package]] name = "h11" version = "0.16.0" @@ -911,15 +594,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "httpx-sse" -version = "0.4.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, -] - [[package]] name = "httpx2" version = "2.12.0" @@ -1077,61 +751,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, ] -[[package]] -name = "langchain" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langgraph" }, - { name = "pydantic" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/5d/0ef368fdcf5df08a394787196fb7a80f473a9c7b3fc4cd2e3b53a7a5853d/langchain-1.4.0.tar.gz", hash = "sha256:08da122a439f738f4c09a5158cfa3d2c1d8bea2d0bde7fbbf4aeb4d7f7fd9d80", size = 729354, upload-time = "2026-09-03T16:59:32.442Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/fa/7da07d9977a5e292ef602c1bb911514ff8206e85dc09a66961874345d57a/langchain-1.4.0-py3-none-any.whl", hash = "sha256:af1dc0161d30944a52ec7844d9bf890d0497b12ec0703069f3503b4003cbbac3", size = 161534, upload-time = "2026-09-03T16:59:31.154Z" }, -] - -[[package]] -name = "langchain-classic" -version = "1.0.8" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langchain-text-splitters" }, - { name = "langsmith" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "sqlalchemy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/8d/65/6b5e8a7ff2f2968652c88a67dcecb925b9d8f0a0ce9458c76cd5a0dbd138/langchain_classic-1.0.8.tar.gz", hash = "sha256:ada0cc341a8a5b80fb24d73bdfaaeb849056ee2d8a41cc468355163fd3667484", size = 10557071, upload-time = "2026-06-10T21:27:54.866Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/99/9a/b8f5cb7490fdbf233088031fc69c9c747439d4097f67f196c1eb4869916d/langchain_classic-1.0.8-py3-none-any.whl", hash = "sha256:1a11ea7fbe630c4f2af2f3873d27718ceac9488cf32d0821030be7cf039a6213", size = 1041536, upload-time = "2026-06-10T21:27:52.767Z" }, -] - -[[package]] -name = "langchain-community" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "aiohttp" }, - { name = "httpx-sse" }, - { name = "langchain-classic" }, - { name = "langchain-core" }, - { name = "langsmith" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.5.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic-settings" }, - { name = "pyyaml" }, - { name = "requests" }, - { name = "sqlalchemy" }, - { name = "tenacity" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ea/0c/e3aca1f2b1c5b95f8b87cb2b6e81a6f20d538c07a128419dc01cef0617b6/langchain_community-0.4.2.tar.gz", hash = "sha256:a99308160d53d7e9b5965ee665e5173709914338210089fd5788ad724432c21e", size = 33268708, upload-time = "2026-05-22T19:42:59.374Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/39/5d97e42a3e95dc2a6d71b2f902a3fae71786131e11d01bddb604accb0ebe/langchain_community-0.4.2-py3-none-any.whl", hash = "sha256:84dd8c5122532394d5b6849a5fc9995ef28e4f77227daeb09f24b3d942e9e466", size = 2364406, upload-time = "2026-05-22T19:42:57.103Z" }, -] - [[package]] name = "langchain-core" version = "1.6.2" @@ -1177,65 +796,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/26/1ef06f56198d631296d646a6223de35bcc6cf9795ceb2442816bc963b84c/langchain_text_splitters-1.1.2-py3-none-any.whl", hash = "sha256:a2de0d799ff31886429fd6e2e0032df275b60ec817c19059a7b46181cc1c2f10", size = 35903, upload-time = "2026-04-16T14:20:38.243Z" }, ] -[[package]] -name = "langgraph" -version = "1.2.11" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langgraph-checkpoint" }, - { name = "langgraph-prebuilt" }, - { name = "langgraph-sdk" }, - { name = "pydantic" }, - { name = "xxhash" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" }, -] - -[[package]] -name = "langgraph-checkpoint" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "ormsgpack" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/dc/e1/089c4c9e0a2fec7f883f82ae8e6a727138d50074cfeb6644bc2d13b1019b/langgraph_checkpoint-4.2.0.tar.gz", hash = "sha256:51a593b6bee684b0818e5d6e58e28ab340c6db7794575056ce7bd1b746a84ed7", size = 180239, upload-time = "2026-08-07T20:05:03.756Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/71/3b475f09bd57d3a5649792c66353312b4432afd843f301739dfcebd157f0/langgraph_checkpoint-4.2.0-py3-none-any.whl", hash = "sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942", size = 56833, upload-time = "2026-08-07T20:05:02.655Z" }, -] - -[[package]] -name = "langgraph-prebuilt" -version = "1.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "langchain-core" }, - { name = "langgraph-checkpoint" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/29/66/ed9b93f56bc17ef22d551892f0ac2b225a97fe0fcf23a511b857f70d590b/langgraph_prebuilt-1.1.0.tar.gz", hash = "sha256:3c579cf6eed2d17f9c157c2d0fcaddcd8688524e7022d3b22b37a3bf4589d528", size = 178833, upload-time = "2026-05-12T03:37:49.332Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/43/3fe1a700b8490ed02679cdbbc8c915eb23a092faf496c9c1118abcd10be3/langgraph_prebuilt-1.1.0-py3-none-any.whl", hash = "sha256:51e311747d755b751d5c6b39b0c1446124d3a7643d2515017e6714b323508fc9", size = 41043, upload-time = "2026-05-12T03:37:48.007Z" }, -] - -[[package]] -name = "langgraph-sdk" -version = "0.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "httpx" }, - { name = "langchain-core" }, - { name = "langchain-protocol" }, - { name = "orjson" }, - { name = "websockets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3f/ae/91446c1fffa04a2dc1f81afbfb5bfff3590452891a72bd9098a656ab2657/langgraph_sdk-0.4.4.tar.gz", hash = "sha256:4e651ffa09de695681579396375377bdde23bedbc8e35b070e615cbd5af7da8b", size = 345789, upload-time = "2026-08-27T21:25:22.593Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/d5/2ad7f3a835c6fcebf58b6669552536ecd52d2dfe4cf49c6c36648fd83572/langgraph_sdk-0.4.4-py3-none-any.whl", hash = "sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64", size = 162270, upload-time = "2026-08-27T21:25:21.072Z" }, -] - [[package]] name = "langsmith" version = "0.12.2" @@ -1410,123 +970,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] -[[package]] -name = "multidict" -version = "6.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, - { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, - { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, - { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, - { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, - { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, - { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, - { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, - { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, - { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, - { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, - { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, - { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, - { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, - { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, - { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, - { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, - { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, - { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, - { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, - { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, - { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, - { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, - { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, - { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, - { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, - { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, - { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, - { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, - { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, - { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, - { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, - { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, - { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, - { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, - { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, - { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, - { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, - { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, - { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, - { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, - { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, - { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, - { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, - { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, - { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, - { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, - { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, - { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, - { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, - { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, - { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, - { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, - { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, - { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, - { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, - { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, - { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, - { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, - { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, - { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, - { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, - { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, - { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, - { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, - { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, - { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, - { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, - { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, - { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, - { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, - { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, - { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, - { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, - { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, - { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, - { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, - { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, - { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, - { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, - { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, - { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, - { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, - { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, - { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, -] - [[package]] name = "mypy" version = "2.3.1" @@ -1849,54 +1292,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" }, ] -[[package]] -name = "ormsgpack" -version = "1.12.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/12/0c/f1761e21486942ab9bb6feaebc610fa074f7c5e496e6962dea5873348077/ormsgpack-1.12.2.tar.gz", hash = "sha256:944a2233640273bee67521795a73cf1e959538e0dfb7ac635505010455e53b33", size = 39031, upload-time = "2026-01-18T20:55:28.023Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/08/8b68f24b18e69d92238aa8f258218e6dfeacf4381d9d07ab8df303f524a9/ormsgpack-1.12.2-cp311-cp311-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bd5f4bf04c37888e864f08e740c5a573c4017f6fd6e99fa944c5c935fabf2dd9", size = 378266, upload-time = "2026-01-18T20:55:59.876Z" }, - { url = "https://files.pythonhosted.org/packages/0d/24/29fc13044ecb7c153523ae0a1972269fcd613650d1fa1a9cec1044c6b666/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34d5b28b3570e9fed9a5a76528fc7230c3c76333bc214798958e58e9b79cc18a", size = 203035, upload-time = "2026-01-18T20:55:30.59Z" }, - { url = "https://files.pythonhosted.org/packages/ad/c2/00169fb25dd8f9213f5e8a549dfb73e4d592009ebc85fbbcd3e1dcac575b/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3708693412c28f3538fb5a65da93787b6bbab3484f6bc6e935bfb77a62400ae5", size = 210539, upload-time = "2026-01-18T20:55:48.569Z" }, - { url = "https://files.pythonhosted.org/packages/1b/33/543627f323ff3c73091f51d6a20db28a1a33531af30873ea90c5ac95a9b5/ormsgpack-1.12.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43013a3f3e2e902e1d05e72c0f1aeb5bedbb8e09240b51e26792a3c89267e181", size = 212401, upload-time = "2026-01-18T20:56:10.101Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5d/f70e2c3da414f46186659d24745483757bcc9adccb481a6eb93e2b729301/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7c8b1667a72cbba74f0ae7ecf3105a5e01304620ed14528b2cb4320679d2869b", size = 387082, upload-time = "2026-01-18T20:56:12.047Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d6/06e8dc920c7903e051f30934d874d4afccc9bb1c09dcaf0bc03a7de4b343/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:df6961442140193e517303d0b5d7bc2e20e69a879c2d774316125350c4a76b92", size = 482346, upload-time = "2026-01-18T20:56:05.152Z" }, - { url = "https://files.pythonhosted.org/packages/66/c4/f337ac0905eed9c393ef990c54565cd33644918e0a8031fe48c098c71dbf/ormsgpack-1.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6a4c34ddef109647c769d69be65fa1de7a6022b02ad45546a69b3216573eb4a", size = 425181, upload-time = "2026-01-18T20:55:37.83Z" }, - { url = "https://files.pythonhosted.org/packages/78/29/6d5758fabef3babdf4bbbc453738cc7de9cd3334e4c38dd5737e27b85653/ormsgpack-1.12.2-cp311-cp311-win_amd64.whl", hash = "sha256:73670ed0375ecc303858e3613f407628dd1fca18fe6ac57b7b7ce66cc7bb006c", size = 117182, upload-time = "2026-01-18T20:55:31.472Z" }, - { url = "https://files.pythonhosted.org/packages/c4/57/17a15549233c37e7fd054c48fe9207492e06b026dbd872b826a0b5f833b6/ormsgpack-1.12.2-cp311-cp311-win_arm64.whl", hash = "sha256:c2be829954434e33601ae5da328cccce3266b098927ca7a30246a0baec2ce7bd", size = 111464, upload-time = "2026-01-18T20:55:38.811Z" }, - { url = "https://files.pythonhosted.org/packages/4c/36/16c4b1921c308a92cef3bf6663226ae283395aa0ff6e154f925c32e91ff5/ormsgpack-1.12.2-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7a29d09b64b9694b588ff2f80e9826bdceb3a2b91523c5beae1fab27d5c940e7", size = 378618, upload-time = "2026-01-18T20:55:50.835Z" }, - { url = "https://files.pythonhosted.org/packages/c0/68/468de634079615abf66ed13bb5c34ff71da237213f29294363beeeca5306/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b39e629fd2e1c5b2f46f99778450b59454d1f901bc507963168985e79f09c5d", size = 203186, upload-time = "2026-01-18T20:56:11.163Z" }, - { url = "https://files.pythonhosted.org/packages/73/a9/d756e01961442688b7939bacd87ce13bfad7d26ce24f910f6028178b2cc8/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:958dcb270d30a7cb633a45ee62b9444433fa571a752d2ca484efdac07480876e", size = 210738, upload-time = "2026-01-18T20:56:09.181Z" }, - { url = "https://files.pythonhosted.org/packages/7b/ba/795b1036888542c9113269a3f5690ab53dd2258c6fb17676ac4bd44fcf94/ormsgpack-1.12.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d379d72b6c5e964851c77cfedfb386e474adee4fd39791c2c5d9efb53505cc", size = 212569, upload-time = "2026-01-18T20:56:06.135Z" }, - { url = "https://files.pythonhosted.org/packages/6c/aa/bff73c57497b9e0cba8837c7e4bcab584b1a6dbc91a5dd5526784a5030c8/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8463a3fc5f09832e67bdb0e2fda6d518dc4281b133166146a67f54c08496442e", size = 387166, upload-time = "2026-01-18T20:55:36.738Z" }, - { url = "https://files.pythonhosted.org/packages/d3/cf/f8283cba44bcb7b14f97b6274d449db276b3a86589bdb363169b51bc12de/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:eddffb77eff0bad4e67547d67a130604e7e2dfbb7b0cde0796045be4090f35c6", size = 482498, upload-time = "2026-01-18T20:55:29.626Z" }, - { url = "https://files.pythonhosted.org/packages/05/be/71e37b852d723dfcbe952ad04178c030df60d6b78eba26bfd14c9a40575e/ormsgpack-1.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcd55e5f6ba0dbce624942adf9f152062135f991a0126064889f68eb850de0dd", size = 425518, upload-time = "2026-01-18T20:55:49.556Z" }, - { url = "https://files.pythonhosted.org/packages/7a/0c/9803aa883d18c7ef197213cd2cbf73ba76472a11fe100fb7dab2884edf48/ormsgpack-1.12.2-cp312-cp312-win_amd64.whl", hash = "sha256:d024b40828f1dde5654faebd0d824f9cc29ad46891f626272dd5bfd7af2333a4", size = 117462, upload-time = "2026-01-18T20:55:47.726Z" }, - { url = "https://files.pythonhosted.org/packages/c8/9e/029e898298b2cc662f10d7a15652a53e3b525b1e7f07e21fef8536a09bb8/ormsgpack-1.12.2-cp312-cp312-win_arm64.whl", hash = "sha256:da538c542bac7d1c8f3f2a937863dba36f013108ce63e55745941dda4b75dbb6", size = 111559, upload-time = "2026-01-18T20:55:54.273Z" }, - { url = "https://files.pythonhosted.org/packages/eb/29/bb0eba3288c0449efbb013e9c6f58aea79cf5cb9ee1921f8865f04c1a9d7/ormsgpack-1.12.2-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:5ea60cb5f210b1cfbad8c002948d73447508e629ec375acb82910e3efa8ff355", size = 378661, upload-time = "2026-01-18T20:55:57.765Z" }, - { url = "https://files.pythonhosted.org/packages/6e/31/5efa31346affdac489acade2926989e019e8ca98129658a183e3add7af5e/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3601f19afdbea273ed70b06495e5794606a8b690a568d6c996a90d7255e51c1", size = 203194, upload-time = "2026-01-18T20:56:08.252Z" }, - { url = "https://files.pythonhosted.org/packages/eb/56/d0087278beef833187e0167f8527235ebe6f6ffc2a143e9de12a98b1ce87/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29a9f17a3dac6054c0dce7925e0f4995c727f7c41859adf9b5572180f640d172", size = 210778, upload-time = "2026-01-18T20:55:17.694Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a2/072343e1413d9443e5a252a8eb591c2d5b1bffbe5e7bfc78c069361b92eb/ormsgpack-1.12.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39c1bd2092880e413902910388be8715f70b9f15f20779d44e673033a6146f2d", size = 212592, upload-time = "2026-01-18T20:55:32.747Z" }, - { url = "https://files.pythonhosted.org/packages/a2/8b/a0da3b98a91d41187a63b02dda14267eefc2a74fcb43cc2701066cf1510e/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:50b7249244382209877deedeee838aef1542f3d0fc28b8fe71ca9d7e1896a0d7", size = 387164, upload-time = "2026-01-18T20:55:40.853Z" }, - { url = "https://files.pythonhosted.org/packages/19/bb/6d226bc4cf9fc20d8eb1d976d027a3f7c3491e8f08289a2e76abe96a65f3/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5af04800d844451cf102a59c74a841324868d3f1625c296a06cc655c542a6685", size = 482516, upload-time = "2026-01-18T20:55:42.033Z" }, - { url = "https://files.pythonhosted.org/packages/fb/f1/bb2c7223398543dedb3dbf8bb93aaa737b387de61c5feaad6f908841b782/ormsgpack-1.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cec70477d4371cd524534cd16472d8b9cc187e0e3043a8790545a9a9b296c258", size = 425539, upload-time = "2026-01-18T20:55:24.727Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e8/0fb45f57a2ada1fed374f7494c8cd55e2f88ccd0ab0a669aa3468716bf5f/ormsgpack-1.12.2-cp313-cp313-win_amd64.whl", hash = "sha256:21f4276caca5c03a818041d637e4019bc84f9d6ca8baa5ea03e5cc8bf56140e9", size = 117459, upload-time = "2026-01-18T20:55:56.876Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d4/0cfeea1e960d550a131001a7f38a5132c7ae3ebde4c82af1f364ccc5d904/ormsgpack-1.12.2-cp313-cp313-win_arm64.whl", hash = "sha256:baca4b6773d20a82e36d6fd25f341064244f9f86a13dead95dd7d7f996f51709", size = 111577, upload-time = "2026-01-18T20:55:43.605Z" }, - { url = "https://files.pythonhosted.org/packages/94/16/24d18851334be09c25e87f74307c84950f18c324a4d3c0b41dabdbf19c29/ormsgpack-1.12.2-cp314-cp314-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bc68dd5915f4acf66ff2010ee47c8906dc1cf07399b16f4089f8c71733f6e36c", size = 378717, upload-time = "2026-01-18T20:55:26.164Z" }, - { url = "https://files.pythonhosted.org/packages/b5/a2/88b9b56f83adae8032ac6a6fa7f080c65b3baf9b6b64fd3d37bd202991d4/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46d084427b4132553940070ad95107266656cb646ea9da4975f85cb1a6676553", size = 203183, upload-time = "2026-01-18T20:55:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/a9/80/43e4555963bf602e5bdc79cbc8debd8b6d5456c00d2504df9775e74b450b/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c010da16235806cf1d7bc4c96bf286bfa91c686853395a299b3ddb49499a3e13", size = 210814, upload-time = "2026-01-18T20:55:33.973Z" }, - { url = "https://files.pythonhosted.org/packages/78/e1/7cfbf28de8bca6efe7e525b329c31277d1b64ce08dcba723971c241a9d60/ormsgpack-1.12.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18867233df592c997154ff942a6503df274b5ac1765215bceba7a231bea2745d", size = 212634, upload-time = "2026-01-18T20:55:28.634Z" }, - { url = "https://files.pythonhosted.org/packages/95/f8/30ae5716e88d792a4e879debee195653c26ddd3964c968594ddef0a3cc7e/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b009049086ddc6b8f80c76b3955df1aa22a5fbd7673c525cd63bf91f23122ede", size = 387139, upload-time = "2026-01-18T20:56:02.013Z" }, - { url = "https://files.pythonhosted.org/packages/dc/81/aee5b18a3e3a0e52f718b37ab4b8af6fae0d9d6a65103036a90c2a8ffb5d/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1dcc17d92b6390d4f18f937cf0b99054824a7815818012ddca925d6e01c2e49e", size = 482578, upload-time = "2026-01-18T20:55:35.117Z" }, - { url = "https://files.pythonhosted.org/packages/bd/17/71c9ba472d5d45f7546317f467a5fc941929cd68fb32796ca3d13dcbaec2/ormsgpack-1.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f04b5e896d510b07c0ad733d7fce2d44b260c5e6c402d272128f8941984e4285", size = 425539, upload-time = "2026-01-18T20:56:04.009Z" }, - { url = "https://files.pythonhosted.org/packages/2e/a6/ac99cd7fe77e822fed5250ff4b86fa66dd4238937dd178d2299f10b69816/ormsgpack-1.12.2-cp314-cp314-win_amd64.whl", hash = "sha256:ae3aba7eed4ca7cb79fd3436eddd29140f17ea254b91604aa1eb19bfcedb990f", size = 117493, upload-time = "2026-01-18T20:56:07.343Z" }, - { url = "https://files.pythonhosted.org/packages/3a/67/339872846a1ae4592535385a1c1f93614138566d7af094200c9c3b45d1e5/ormsgpack-1.12.2-cp314-cp314-win_arm64.whl", hash = "sha256:118576ea6006893aea811b17429bfc561b4778fad393f5f538c84af70b01260c", size = 111579, upload-time = "2026-01-18T20:55:21.161Z" }, - { url = "https://files.pythonhosted.org/packages/49/c2/6feb972dc87285ad381749d3882d8aecbde9f6ecf908dd717d33d66df095/ormsgpack-1.12.2-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7121b3d355d3858781dc40dafe25a32ff8a8242b9d80c692fd548a4b1f7fd3c8", size = 378721, upload-time = "2026-01-18T20:55:52.12Z" }, - { url = "https://files.pythonhosted.org/packages/a3/9a/900a6b9b413e0f8a471cf07830f9cf65939af039a362204b36bd5b581d8b/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ee766d2e78251b7a63daf1cddfac36a73562d3ddef68cacfb41b2af64698033", size = 203170, upload-time = "2026-01-18T20:55:44.469Z" }, - { url = "https://files.pythonhosted.org/packages/87/4c/27a95466354606b256f24fad464d7c97ab62bce6cc529dd4673e1179b8fb/ormsgpack-1.12.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:292410a7d23de9b40444636b9b8f1e4e4b814af7f1ef476e44887e52a123f09d", size = 212816, upload-time = "2026-01-18T20:55:23.501Z" }, - { url = "https://files.pythonhosted.org/packages/73/cd/29cee6007bddf7a834e6cd6f536754c0535fcb939d384f0f37a38b1cddb8/ormsgpack-1.12.2-cp314-cp314t-win_amd64.whl", hash = "sha256:837dd316584485b72ef451d08dd3e96c4a11d12e4963aedb40e08f89685d8ec2", size = 117232, upload-time = "2026-01-18T20:55:45.448Z" }, -] - [[package]] name = "packaging" version = "26.3" @@ -1945,117 +1340,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/6f/84908cad2d6aa5144abcf7b42709fe4fdb459bc640ec7ac5786e7693dabc/prompt_toolkit-3.0.53-py3-none-any.whl", hash = "sha256:01c0891d7f9237d5e339f7d3e42cdae80b7534abb1c7c0e3352efba6231492f2", size = 392288, upload-time = "2026-07-26T20:56:12.512Z" }, ] -[[package]] -name = "propcache" -version = "0.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, - { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, - { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, - { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, - { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, - { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, - { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, - { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, - { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, - { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, - { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, - { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, - { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, - { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, - { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, - { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, - { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, - { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, - { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, - { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, - { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, - { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, - { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, - { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, - { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, - { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, - { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, - { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, - { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, - { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, - { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, - { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, - { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, - { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, - { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, - { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, - { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, - { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, - { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, - { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, - { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, - { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, - { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, - { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, - { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, - { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, - { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, - { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, - { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, - { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, - { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, - { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, - { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, - { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, - { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, - { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, - { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, - { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, - { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, - { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, - { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, - { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, - { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, - { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, - { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, - { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, - { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, - { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, - { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, - { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, - { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, - { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, - { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, - { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, - { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, - { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, - { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, - { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, - { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, - { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, - { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, - { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, - { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, - { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, - { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, - { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, -] - [[package]] name = "pyasn1" version = "0.6.4" @@ -2285,8 +1569,6 @@ dependencies = [ { name = "google-genai" }, { name = "httpx" }, { name = "jieba" }, - { name = "langchain" }, - { name = "langchain-community" }, { name = "langchain-core" }, { name = "langchain-text-splitters" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, @@ -2322,8 +1604,6 @@ requires-dist = [ { name = "google-genai", specifier = ">=2.22.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "jieba", specifier = ">=0.42.1" }, - { name = "langchain", specifier = ">=1.4.0" }, - { name = "langchain-community", specifier = ">=0.4.2" }, { name = "langchain-core", specifier = ">=1.6.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "numpy", specifier = ">=2.4.6" }, @@ -2689,48 +1969,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] -[[package]] -name = "sqlalchemy" -version = "2.0.52" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3b/21/77b4c147963073040dc3c3a5cb7a8c3001a1893c0209432cb77f9df836aa/sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97", size = 9945637, upload-time = "2026-08-11T19:07:09.829Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/08/cc5f7627b92f1456bc0b5fb7e98af4600248abe422a44da0d17a3fe6a448/sqlalchemy-2.0.52-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0c3ce43907374889f3352bdcc6195c970148a2cb71574cd0237a5071a37fb6c", size = 2172460, upload-time = "2026-08-11T20:58:22.429Z" }, - { url = "https://files.pythonhosted.org/packages/ed/dc/9a2abad8bfc8fdcd38c64adc056aeefab7aaa96ecd32f5e8c140e6375f17/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0d48c4b80717c61385b4e966e087c839a66cfd7b780641dcb428f4dba65608", size = 3355720, upload-time = "2026-08-11T21:00:06.746Z" }, - { url = "https://files.pythonhosted.org/packages/a8/73/e75597b5841043e3c74055d00d4feb53d9a49a5c89ba2450d2d9aab53597/sqlalchemy-2.0.52-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:938325a5373267afc53bfbe72983b20fbd64ca47842aac62433c3da1137ecff1", size = 3354394, upload-time = "2026-08-11T21:05:51.454Z" }, - { url = "https://files.pythonhosted.org/packages/12/25/410fbc6c2f1fa8310f4ef1b6847d47d0ac1c042c7b4e81eaaca063d030a9/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f8438a98d49424acf69d0d53c0a522951dfe49a6f2d86417fbb37ad3066ab43", size = 3306991, upload-time = "2026-08-11T21:00:08.603Z" }, - { url = "https://files.pythonhosted.org/packages/b2/ba/25ffd5c24681ea4b46e62c80ceca8200ce204de1773366321306cf3f608a/sqlalchemy-2.0.52-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4699dbb8d396d199e7e78fd4d525e3ad3d6008a9c8c0160b87e74c606c2c3736", size = 3327454, upload-time = "2026-08-11T21:05:53.368Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f1/0f1b1d4800e51218e736a06ed55a3b2a59c257600bbaca7673bf13d2dbec/sqlalchemy-2.0.52-cp311-cp311-win32.whl", hash = "sha256:cef328349452ae152637df4d11ce5a0919ecdf0a363e16c830c3518ee33bde72", size = 2131248, upload-time = "2026-08-11T21:09:50.765Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f0/04d2ac5ad66f3d31278f37064ed5f5ef3fe653f7bdaa67036663f223d186/sqlalchemy-2.0.52-cp311-cp311-win_amd64.whl", hash = "sha256:f1c850792a3b25a3ad74dade3f05e4f402cdebfea27438bcadafaa1617f77bcc", size = 2156943, upload-time = "2026-08-11T21:09:51.979Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d5/1b77a026d161f98a08f11af1a5f6c47b98ee7c7e2648af525a1004826c78/sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727", size = 2170940, upload-time = "2026-08-11T20:58:11.25Z" }, - { url = "https://files.pythonhosted.org/packages/54/bd/f444444adb37b5d53753fb1730ee7a421628e2e3b756c4da461af7e6394a/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee", size = 3383415, upload-time = "2026-08-11T21:02:38.534Z" }, - { url = "https://files.pythonhosted.org/packages/be/57/2eadf93a552568c57e8680b7e58bb5e9770d80942a1bdbaf4f2f63f0d7c8/sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b", size = 3398577, upload-time = "2026-08-11T21:16:59.092Z" }, - { url = "https://files.pythonhosted.org/packages/15/c3/2887cf9dd111d1fbf05d22165b404c221ef43e029f7a2695e7302f27a7cc/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee", size = 3328225, upload-time = "2026-08-11T21:02:40.183Z" }, - { url = "https://files.pythonhosted.org/packages/02/0f/466bdf9e1feeeef5587f868c187d8687e21ff8c85b1775e9041130181132/sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf", size = 3357374, upload-time = "2026-08-11T21:17:01.076Z" }, - { url = "https://files.pythonhosted.org/packages/22/20/5c2b4583904af4173076dda1c9e53c9e2ffc7a702d2efde0216bbacbf7cb/sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e", size = 2129366, upload-time = "2026-08-11T21:14:50.991Z" }, - { url = "https://files.pythonhosted.org/packages/ed/06/543dab8ef62d4e9fb96fb31a30c2b8b14a8763bccf48d428294d6b3041c0/sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca", size = 2157344, upload-time = "2026-08-11T21:14:52.453Z" }, - { url = "https://files.pythonhosted.org/packages/7f/18/e30c6fe1eca1bf34a39fbdd6066121cc9974c850faf6f349eac563697a26/sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43", size = 2167724, upload-time = "2026-08-11T20:58:12.679Z" }, - { url = "https://files.pythonhosted.org/packages/d0/56/2e17d161a4f7ecc1c2ffb93e607b4e1898bb551b451b283235acb8f6ce47/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1", size = 3321189, upload-time = "2026-08-11T21:02:41.932Z" }, - { url = "https://files.pythonhosted.org/packages/cf/b8/8490916e893f3f8d74dc9cc54c078619364999dee37047a188e73abbc852/sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c", size = 3338185, upload-time = "2026-08-11T21:17:02.597Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f7/752cc8ee453da222829b3f5c4613614bf750d97429363b70414fa10478e4/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15", size = 3271698, upload-time = "2026-08-11T21:02:43.963Z" }, - { url = "https://files.pythonhosted.org/packages/51/e6/074ade0c07b9e4c8e8bca46820320ed94df9702afdb6f2af06623068d2e6/sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057", size = 3308936, upload-time = "2026-08-11T21:17:04.172Z" }, - { url = "https://files.pythonhosted.org/packages/66/07/557c0d04716705599227945ac14e0a17ad0338e899f37d8c2ddff4dcc663/sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d", size = 2127308, upload-time = "2026-08-11T21:14:53.879Z" }, - { url = "https://files.pythonhosted.org/packages/96/4e/226eda27654318ce525d043025221f689abef883da2c7126f9065121618c/sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751", size = 2153876, upload-time = "2026-08-11T21:14:55.527Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f5/71cb30af58c9b80a4e1fac0b73bb48f86d497a774a6a2eb6d2f1e657bb73/sqlalchemy-2.0.52-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:410d52be41d17f1a236d19520fbe776257dc16516ed06bd16d433311842aefd9", size = 2169537, upload-time = "2026-08-11T20:58:13.855Z" }, - { url = "https://files.pythonhosted.org/packages/4c/93/d07ebd645d1b07b6b5ed63450a70f063a346a7e0f2c8810daf2e532400cb/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfe9ce533dbe4d0a2ae1486546619bd30b76bcd670539a44d910361376175f5e", size = 3319606, upload-time = "2026-08-11T21:02:45.829Z" }, - { url = "https://files.pythonhosted.org/packages/ae/5c/290c84c7c2566ecd3b65baaae0fddec9bc33b033b398a06123bb86fbfc6e/sqlalchemy-2.0.52-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:812bae5138bfc0aa46fb0686da0fc7f581f68e2bbb05bc24c3713bebaedd1437", size = 3323642, upload-time = "2026-08-11T21:17:05.675Z" }, - { url = "https://files.pythonhosted.org/packages/13/f5/2cc160590ca49173359557880b92a0572293ccb899e8f6cedf150c5a3ddf/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:50bff43b632a56fbf5ed9afdd76307e1512b62051bcd5afb341ae67205bbb6c8", size = 3268125, upload-time = "2026-08-11T21:02:47.649Z" }, - { url = "https://files.pythonhosted.org/packages/35/f3/ea8933fc9f7d1353e9c2ff9965eae687c4cef181120574591ed2fa0633e1/sqlalchemy-2.0.52-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:49565daf5af554f538e23aef1fc81a95a4e49658f152285e45c02f5fc44f04cd", size = 3289516, upload-time = "2026-08-11T21:17:07.267Z" }, - { url = "https://files.pythonhosted.org/packages/45/67/05cf86541c1e1716fca1e4a996954a439cd74501707cda607fb7cb02ef50/sqlalchemy-2.0.52-cp314-cp314-win32.whl", hash = "sha256:ab9da41e61b9979b910499d633b241df20c51ee5037e5405b11c2faac3cbe1a2", size = 2130249, upload-time = "2026-08-11T21:14:57.273Z" }, - { url = "https://files.pythonhosted.org/packages/96/d7/8ac6ffa1e36169e762ef65bd835046abb2251b1bc17f8f6708e14ed8d31f/sqlalchemy-2.0.52-cp314-cp314-win_amd64.whl", hash = "sha256:a593db51b3bae75db17a5738ad5f992244b3a03863f83c28117ee482c6a3f76d", size = 2156718, upload-time = "2026-08-11T21:14:58.667Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4b/e01a737eef378e734cc6394a82248a6ce13b167dfa36c731075ce9fc9c64/sqlalchemy-2.0.52-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1e61d08bdf4ee2f41024569e3400de7d6734ba498144766b11260936ccfa582", size = 2190344, upload-time = "2026-08-11T19:53:21.393Z" }, - { url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89", size = 1950700, upload-time = "2026-08-11T20:47:21.603Z" }, -] - [[package]] name = "stevedore" version = "5.9.1" @@ -3239,105 +2477,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/f3/1ac078fc8fceadcf066469acecacb35d2821cbfaf7d6fc5ac2107c7a314d/xxhash-4.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fac4832b638000106207bc44e44b9616a6a416aaee56c62b01d61f3705e49f58", size = 37252, upload-time = "2026-08-17T08:24:06.652Z" }, ] -[[package]] -name = "yarl" -version = "1.24.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "idna" }, - { name = "multidict" }, - { name = "propcache" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, - { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, - { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, - { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, - { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, - { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, - { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, - { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, - { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, - { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, - { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, - { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, - { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, - { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, - { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, - { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, - { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, - { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, - { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, - { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, - { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, - { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, - { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, - { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, - { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, - { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, - { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, - { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, - { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, - { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, - { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, - { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, - { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, - { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, - { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, - { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, - { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, - { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, - { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, - { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, - { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, - { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, - { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, - { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, - { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, - { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, - { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, - { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, - { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, - { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, - { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, - { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, - { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, - { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, - { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, - { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, - { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, - { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, - { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, - { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, - { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, - { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, - { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, - { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, - { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, - { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, - { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, - { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, -] - [[package]] name = "zstandard" version = "0.25.0" From 6b515acdacc134f0c25b6937a29ca8ab28daf111 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 16:14:20 +0800 Subject: [PATCH 27/31] =?UTF-8?q?fix:=20=E6=A3=80=E7=B4=A2=E8=A7=84?= =?UTF-8?q?=E6=A8=A1=E4=B8=8A=E7=95=8C=E3=80=81bool=20=E7=BB=95=E8=BF=87?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E7=9A=84=E7=9C=9F=E5=9B=A0=EF=BC=8C=E4=BB=A5?= =?UTF-8?q?=E5=8F=8A=E6=B5=8B=E8=AF=95=E5=81=87=E7=BB=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第三轮复核(4 个审查 agent)在 0cb770a 之上挖出的缺口,全部为新增发现, 存在于 453d096 基线,属前几轮遗漏。 配置边界(可用性缺陷): - chat_top_k 与 retrieval_candidate_multiplier 补齐上界。effective_top_k = chat_top_k * multiplier 直通 faiss_index.search(),FAISS 既不报错也不截断: 实测 chat_top_k=10**9 单次查询分配约 3.6 GB 数组、RSS 涨 10.3 GB、挂起 8.4 秒。 现限制 top_k ≤ 10000、multiplier ≤ 100。 - kb_child_chunk_size 必须严格小于 kb_chunk_size。此前 parent=child=300 被接受, 实测不同父块数等于分块数(15/15),即每个父块只产出一个子块,父子检索退化为 单层且无任何报错。与既有 overlap < size 同属跨字段约束。 - 两个融合权重不得同时为 0。_weight_tuple() 在 total <= 0 时返回 (0.0, 0.0), 所有候选得分为 0,再叠加 weighted 策略启用阈值过滤(默认 0.4)会导致检索结果 被全部丢弃且静默返回空。单个为 0 仍合法。 bool 绕过数值校验(根因比现象深一层): - pydantic 的 lax 模式会在 field_validator 之前把 True 强转成 1,因此原先 6 处 isinstance(value, bool) guard 全部不可达——实测 chat_top_k / log_retention_days / chat_vector_weight 等 10 个字段的 True 均被静默接受为 1 / 1.0。现给这些 validator 加 mode="before" 让 guard 生效,并新增 _coerce_number() 统一处理 mode="before" 下收到的原始输入(.env/TOML 是字符串,必须继续支持)。 - chat_temperature 与 retrieval_candidate_multiplier 补同样的布尔短路。 示例配置: - .env.example 的 DEFAULT_LLM_PROVIDER="openai" 是生效的环境变量,优先级高于 config.toml,按示例原样复制会把 default_llm_provider 从 google 改成 openai, 导致 14 条模型配置因密钥为空串在运行期失败(而 embedding/vector 都选了零凭证 通道,证明零凭证默认可行)。现改为注释并说明原因。 测试质量(三处假绿 / 零区分度): - test_numeric_settings_reject_illegal_values 原先断言 match=field_name,而跨字段 model_validator 的报错文本也含这些字段名(「kb_chunk_overlap 必须小于 kb_chunk_size。」),删除 validate_positive_sizes 后 3 例仍全绿。现改为断言各 validator 独有的消息片段,变异下失败数从 2 提升到 5。 - Ark type 键回归测试的夹具原先写 type="function",与硬编码值相同,把透传改成 硬编码的变异体无法被捕获。现改用非 "function" 的取值,并新增覆盖「缺 type 时 两侧默认值对齐」的交叉断言。 - 新增 12 例布尔短路测试。 验证命令: - uv run pytest -q → 1154 passed(基线 1135,新增 19) - 红-绿验证:三处测试修复各做定点变异,均在回退源码后恰好变红 - uv run ruff check / ruff format --check → 通过 - uv run mypy --cache-dir /tmp/pyrag-kit-mypy main.py src → 无问题 - uv run bandit -r main.py src scripts -ll → 0 issues - uv lock --check / git diff --check / compileall → 通过 - uv run main.py --smoke-test → smoke test ok - env 字符串路径回归确认:CHAT_TOP_K='3' 等仍正确解析为 int 配置影响:越界的检索规模、倒置的父子分片、双零权重、以及 bool 形式的数值配置 现在均在加载期显式报错,此前被静默接受。 Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 6 +- CHANGELOG.md | 10 ++ src/utils/config.py | 152 +++++++++++++++++++---- tests/providers/test_sdk_capabilities.py | 35 +++++- tests/test_config.py | 128 ++++++++++++++++--- 5 files changed, 286 insertions(+), 45 deletions(-) diff --git a/.env.example b/.env.example index 69e6e7f..26527e5 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,11 @@ # 聊天模型或 OpenAI 兼容渠道 OPENAI_API_KEY="sk-your-openai-compatible-key" OPENAI_API_BASE="https://api.openai.com/v1" -DEFAULT_LLM_PROVIDER="openai" +# 默认聊天渠道由 config.toml 的 default_llm_provider 决定(默认 "google")。 +# 不要放开下一行:环境变量优先于 config.toml,一旦启用就会覆盖 TOML 里的 +# 零凭证默认值,导致「按示例复制即可运行」失效(14 条模型配置因密钥为空串 +# 在运行期失败)。若确实要用 OpenAI 兼容渠道,请在 config.toml 中改。 +# DEFAULT_LLM_PROVIDER="openai" # 其他可选模型密钥 ANTHROPIC_API_KEY="" diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fd95a6..0ae3e81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,16 @@ - Google `tool_choice` 的异常文本在源头脱敏:裸 pydantic `ValidationError` 会把违规取值原文写进 `input_value=...`,而 `types.ToolConfig` 允许的键集里就有 `api_key` 这类凭证形态的键。两个消毒器(`redact_sensitive_text`、`safe_exception_text`)虽然都能拦住,但在拼消息前收口才是纵深防御。 - `release.yml` 的手动发布路径补交叉校验:`workflow_dispatch` 的 `tag`/`version` 是手填的,此前直接透传,会发出一个版本号与 `pyproject.toml`/`CHANGELOG.md` 不符的 release。现校验 `version` 与 `pyproject.toml` 一致、`tag` 等于 `v{version}`、且 CHANGELOG 已有该版本条目。 +- 检索规模补齐上界:`chat_top_k` 此前只校验下界,而 `effective_top_k = chat_top_k * retrieval_candidate_multiplier` 会直通 `faiss_index.search()`,FAISS 既不报错也不截断。实测 `chat_top_k=10**9` 被接受,单次查询真实分配约 3.6 GB 数组、进程 RSS 涨 10.3 GB 并挂起 8.4 秒。现限制 `chat_top_k ≤ 10000`、`retrieval_candidate_multiplier ≤ 100`。 +- 补齐父子分片的层级约束:`kb_child_chunk_size >= kb_chunk_size` 此前被静默接受,实测 `parent=child=300` 时不同父块数等于分块数(15/15),即每个父块只产出一个子块,「先粗后细」的父子检索退化为单层。现要求子分片严格小于父分片——与既有的 `overlap < size` 同属无法用单字段 validator 表达的约束。 +- 混合检索权重不得同时为 0:`retrieval_service._weight_tuple()` 在 `total <= 0` 时返回 `(0.0, 0.0)`,所有候选得分为 0,再叠加 `weighted` 策略启用阈值过滤(默认 0.4)会导致**检索结果被全部丢弃且无任何报错**。单个权重为 0 仍合法(纯向量 / 纯关键词检索)。 +- 修复布尔值绕过数值校验的真因:`bool` 是 `int` 的子类,而 pydantic 的 lax 模式会在 `field_validator` **之前**把 `True` 强转成 `1`,因此原有的 6 处 `isinstance(value, bool)` guard 全部是**不可达的死代码**——实测 `Settings(chat_top_k=True)`、`log_retention_days=True`、`chat_vector_weight=True` 等 10 个字段均被静默接受为 `1`/`1.0`。现给这些 validator 加 `mode="before"` 让 guard 真正生效,并新增 `_coerce_number()` 统一处理 `mode="before"` 下收到的原始输入(字符串数值来自 `.env`/TOML,必须继续支持)。 +- `chat_temperature` 与 `retrieval_candidate_multiplier` 的 validator 补上同样的布尔短路(两者此前也用 `float(v)`/`int(v)` 静默接受 `True`)。 +- 修复 `.env.example` 的默认渠道覆盖:`DEFAULT_LLM_PROVIDER="openai"` 是生效的环境变量,而环境变量优先于 `config.toml`,因此按示例原样复制后 `default_llm_provider` 被改成 `openai`、覆盖了 TOML 里的 `google`,导致 14 条模型配置因密钥为空串在运行期失败(而 `default_embedding_provider=local-hash`、`default_vector_store=faiss` 都选了零凭证通道,证明零凭证默认可行)。现改为注释并说明原因。 +- 修复三例「假绿」测试:`test_numeric_settings_reject_illegal_values` 原先用 `match=field_name` 断言,而跨字段 `model_validator` 的报错文本里也含 `kb_chunk_size` 等字段名(「kb_chunk_overlap 必须小于 kb_chunk_size。」),删除被保护的 `validate_positive_sizes` 后这 3 例仍然全绿。现改为断言各 validator **独有的消息片段**,变异验证下失败数从 2 提升到 5。 +- 补强 Ark `tool_calls` 的 `type` 回归测试:夹具原先写 `type="function"`,与硬编码值相同,因此把透传改成硬编码 `"function"` 的变异体无法被捕获(零区分度)。现夹具改用非 `"function"` 的取值,并新增一条覆盖「缺 `type` 时两侧默认值对齐」的交叉断言——此前断言只覆盖显式传值路径,两条路径的 default 一旦分叉测试仍全绿。 +- 新增 12 例布尔短路测试,覆盖 `log_retention_days`、`kb_chunk_size`、`kb_chunk_overlap`、`chat_top_k`、`retrieval_candidate_multiplier`、`chat_vector_weight`、`chat_score_threshold`、`chat_temperature` 等字段拒绝 `True`。 + ## [1.3.0] - 2026-03-20 ### 运行与配置 diff --git a/src/utils/config.py b/src/utils/config.py index 7c53bb3..3aee685 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -141,6 +141,38 @@ def normalize_protocol(cls, value: Any) -> ModelProtocol | None: # ================================================================= +# 检索规模的上界。不是洁癖:``effective_top_k = chat_top_k * +# retrieval_candidate_multiplier`` 直通 ``faiss_index.search()``,FAISS 既不报错 +# 也不截断。实测 ``chat_top_k=10**9`` 单次查询分配约 3.6 GB 数组、RSS 涨 +# 10.3 GB、耗时 8.4 秒。10_000 远超任何真实场景(默认 top_k=5 × multiplier=3)。 +_MAX_RETRIEVAL_TOP_K = 10_000 +_MAX_RETRIEVAL_MULTIPLIER = 100 + + +def _coerce_number(value: Any, field_label: str) -> int | float: + """把 ``mode="before"`` 收到的原始输入转成数值。 + + ``mode="before"`` 的 validator 拿到的是**未经 pydantic 强转**的原始值: + ``.env``/TOML 来源是字符串(``"5"``),Python 调用方可能是 ``int``/``float``, + 而 ``bool`` 是 ``int`` 的子类(``int(True) == 1``)必须显式拒绝,否则 + ``Settings(chat_top_k=True)`` 会静默变成 ``1``。 + """ + if isinstance(value, bool): + raise ValueError(f"{field_label} 不能是布尔值 {value!r}。") + if isinstance(value, (int, float)): + return value + if isinstance(value, str): + text = value.strip() + try: + return int(text) + except ValueError: + try: + return float(text) + except ValueError as exc: + raise ValueError(f"无法把 {field_label} 的 {value!r} 转换为数值。") from exc + raise ValueError(f"{field_label} 必须是数值,但得到 {type(value).__name__}。") + + class Settings(BaseSettings): """ 定义整个应用的配置,使用Pydantic进行类型校验和分层加载。 @@ -301,53 +333,86 @@ def model_dump_json(self, *args: Any, **kwargs: Any) -> str: ) # --- [VALIDATORS] --- - @field_validator("chat_top_k") + @field_validator("chat_top_k", mode="before") @classmethod - def validate_chat_top_k(cls, value: int) -> int: - if isinstance(value, bool) or value < 1: - raise ValueError("chat_top_k 必须是大于等于 1 的整数。") - return value - - @field_validator("chat_score_threshold") + def validate_chat_top_k(cls, value: Any) -> int: + # 上界不是洁癖:effective_top_k = chat_top_k * retrieval_candidate_multiplier + # 会直通 faiss_index.search(),FAISS 不报错也不截断。实测 chat_top_k=10**9 + # 时单次查询真实分配约 3.6 GB 数组、RSS 涨 10.3 GB 并挂起 8.4 秒。 + number = _coerce_number(value, "chat_top_k") + if isinstance(number, float) and not number.is_integer(): + raise ValueError(f"chat_top_k 必须是整数,但得到 {number}。") + number = int(number) + if not 1 <= number <= _MAX_RETRIEVAL_TOP_K: + raise ValueError(f"chat_top_k 必须是 1 到 {_MAX_RETRIEVAL_TOP_K} 之间的整数。") + return number + + @field_validator("chat_score_threshold", mode="before") @classmethod - def validate_chat_score_threshold(cls, value: float) -> float: - if isinstance(value, bool) or not 0 <= value <= 1: + def validate_chat_score_threshold(cls, value: Any) -> float: + number = float(_coerce_number(value, "chat_score_threshold")) + if not 0 <= number <= 1: raise ValueError("chat_score_threshold 必须在 0 到 1 之间。") - return value + return number - @field_validator("log_retention_days") + @field_validator("log_retention_days", mode="before") @classmethod - def validate_log_retention_days(cls, value: int) -> int: - if isinstance(value, bool) or value < 1: + def validate_log_retention_days(cls, value: Any) -> int: + number = _coerce_number(value, "log_retention_days") + if isinstance(number, float) and not number.is_integer(): + raise ValueError(f"log_retention_days 必须是整数,但得到 {number}。") + number = int(number) + if number < 1: raise ValueError("log_retention_days 必须是大于等于 1 的整数。") - return value + return number @field_validator( "kb_chunk_size", "kb_child_chunk_size", "kb_embedding_batch_size", + mode="before", ) @classmethod - def validate_positive_sizes(cls, value: int) -> int: - if isinstance(value, bool) or value < 1: + def validate_positive_sizes(cls, value: Any) -> int: + number = _coerce_number(value, "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size") + if isinstance(number, float) and not number.is_integer(): + raise ValueError( + "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size 必须是整数。" + ) + number = int(number) + if number < 1: raise ValueError( "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size 必须是大于等于 1 的整数。" ) - return value + return number - @field_validator("kb_chunk_overlap", "kb_child_chunk_overlap") + @field_validator("kb_chunk_overlap", "kb_child_chunk_overlap", mode="before") @classmethod - def validate_non_negative_overlap(cls, value: int) -> int: - if isinstance(value, bool) or value < 0: + def validate_non_negative_overlap(cls, value: Any) -> int: + number = _coerce_number(value, "kb_chunk_overlap/kb_child_chunk_overlap") + if isinstance(number, float) and not number.is_integer(): + raise ValueError("kb_chunk_overlap/kb_child_chunk_overlap 必须是整数。") + number = int(number) + if number < 0: raise ValueError("kb_chunk_overlap/kb_child_chunk_overlap 必须是非负整数。") - return value + return number - @field_validator("chat_vector_weight", "chat_keyword_weight") + @field_validator("chat_vector_weight", "chat_keyword_weight", mode="before") @classmethod - def validate_hybrid_weights(cls, value: float) -> float: - if isinstance(value, bool) or not 0 <= value <= 1: + def validate_hybrid_weights(cls, value: Any) -> float: + number = float(_coerce_number(value, "chat_vector_weight/chat_keyword_weight")) + if not 0 <= number <= 1: raise ValueError("chat_vector_weight/chat_keyword_weight 必须在 0 到 1 之间。") - return value + return number + + @model_validator(mode="after") + def validate_retrieval_size_bounds(self) -> "Settings": + """检索规模必须有上界,否则单次查询就能耗尽内存。""" + if self.retrieval_candidate_multiplier > _MAX_RETRIEVAL_MULTIPLIER: + raise ValueError( + f"retrieval_candidate_multiplier 必须不超过 {_MAX_RETRIEVAL_MULTIPLIER}。" + ) + return self @model_validator(mode="after") def validate_overlap_smaller_than_size(self) -> "Settings": @@ -362,6 +427,37 @@ def validate_overlap_smaller_than_size(self) -> "Settings": raise ValueError("kb_child_chunk_overlap 必须小于 kb_child_chunk_size。") return self + @model_validator(mode="after") + def validate_child_chunk_size_not_larger_than_parent(self) -> "Settings": + """子分片不得大于父分片,否则层级结构静默退化为一层。 + + 实测 ``kb_chunk_size=300, kb_child_chunk_size=1500`` 被接受后,父块数 + 从 2 涨到 10(每个父块只产出 1 个子块),「先粗后细」的父子检索意图 + 失效,且没有任何报错。同属无法用单字段 validator 表达的约束。 + """ + # 相等同样要拒:实测 parent=child=300 时不同父块数 == 分块数(15/15), + # 即每个父块只产出一个子块,层级与 child>parent 一样退化为一层。 + if self.kb_child_chunk_size >= self.kb_chunk_size: + raise ValueError( + "kb_child_chunk_size 必须小于 kb_chunk_size(子分片不得大于或等于父分片)。" + ) + return self + + @model_validator(mode="after") + def validate_hybrid_weights_not_both_zero(self) -> "Settings": + """两个融合权重不得同时为 0,否则检索结果被静默全部丢弃。 + + ``retrieval_service._weight_tuple()`` 在 ``total <= 0`` 时返回 + ``(0.0, 0.0)``,所有候选得分为 0;再叠加 ``weighted`` 策略会启用阈值 + 过滤(默认 ``chat_score_threshold=0.4``),结果是空列表且无任何报错。 + 单个为 0 是合法的(纯向量 / 纯关键词检索)。 + """ + if self.chat_vector_weight == 0 and self.chat_keyword_weight == 0: + raise ValueError( + "chat_vector_weight 与 chat_keyword_weight 不能同时为 0,否则检索结果会被全部丢弃。" + ) + return self + @field_validator("log_level", mode="before") @classmethod def validate_log_level(cls, v: str) -> str: @@ -392,6 +488,9 @@ def validate_non_llm_protocol( @classmethod def validate_chat_temperature(cls, v: Any) -> float: """验证聊天温度在 0.0 到 1.0 之间。""" + # ``bool`` 是 ``int`` 的子类,``float(True) == 1.0`` 会被静默接受。 + if isinstance(v, bool): + raise ValueError(f"聊天温度必须是数字,不能是布尔值 {v!r}。") try: value = float(v) except (ValueError, TypeError) as exc: @@ -461,6 +560,9 @@ def warn_retired_base_urls(self) -> "Settings": @classmethod def validate_retrieval_candidate_multiplier(cls, v: Any) -> int: """验证检索候选过量招募倍率。""" + # ``bool`` 是 ``int`` 的子类,``int(True) == 1`` 会被静默接受。 + if isinstance(v, bool): + raise ValueError(f"检索候选倍率必须是整数,不能是布尔值 {v!r}。") try: value = int(v) except (ValueError, TypeError) as exc: diff --git a/tests/providers/test_sdk_capabilities.py b/tests/providers/test_sdk_capabilities.py index 2ba6a47..b1adb88 100644 --- a/tests/providers/test_sdk_capabilities.py +++ b/tests/providers/test_sdk_capabilities.py @@ -2472,7 +2472,10 @@ def test_ark_chat_completions_tool_calls_carry_type_key(): tool_calls=[ SimpleNamespace( id="call-1", - type="function", + # 故意用非 "function" 的 type:夹具若写 "function", + # 硬编码 "function" 的变异体与透传 call.type 输出相同, + # 测试对「值是否真来自 SDK」零区分度。 + type="custom_tool_call", function=SimpleNamespace(name="lookup", arguments='{"id": 1}'), ) ], @@ -2489,13 +2492,41 @@ def test_ark_chat_completions_tool_calls_carry_type_key(): assert len(result.tool_calls) == 1 call = result.tool_calls[0] - assert call["type"] == "function" + # 必须是 SDK 回传的原值,而不是被硬编码成 "function" + assert call["type"] == "custom_tool_call" assert set(call) == {"id", "type", "name", "arguments"} # 与 OpenAI 兼容侧逐键对齐,防止两条路径再次分叉。 oai_calls = OpenAICompatibleProvider._extract_tool_calls(response) assert call == oai_calls[0] + # 交叉断言必须也覆盖「缺 type 时两侧各自的默认值」:夹具若显式给了 type, + # 上面的对齐只验证透传路径,两条路径的 default 一旦分叉(例如一侧被改成 + # "function_MUTATED")测试仍然全绿。 + missing_type = SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace( + content=None, + tool_calls=[ + SimpleNamespace( + id="call-1", + function=SimpleNamespace(name="lookup", arguments='{"id": 1}'), + ) + ], + ), + finish_reason="tool_calls", + ) + ], + usage=None, + output=None, + output_text=None, + ) + assert ( + VolcengineProvider._extract_result(missing_type).tool_calls[0] + == OpenAICompatibleProvider._extract_tool_calls(missing_type)[0] + ) + def test_ark_chat_completions_tool_calls_default_type_to_function(): """Ark 未回传 ``type`` 时也要补 ``function``,与 openai_compatible 一致。""" diff --git a/tests/test_config.py b/tests/test_config.py index f18f001..73842fc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -78,30 +78,39 @@ def test_settings_model_validation(): @pytest.mark.parametrize( - ("field_name", "bad_value"), + ("field_name", "bad_value", "expected_message"), [ - ("log_retention_days", -5), - ("log_retention_days", 0), - ("kb_chunk_size", 0), - ("kb_chunk_size", -100), - ("kb_chunk_overlap", -1), - ("kb_child_chunk_size", 0), - ("kb_child_chunk_overlap", -1), - ("kb_embedding_batch_size", 0), - ("kb_embedding_batch_size", -1), - ("chat_vector_weight", -1.0), - ("chat_vector_weight", 5.0), - ("chat_keyword_weight", 2.0), + ("log_retention_days", -5, "log_retention_days 必须是大于等于 1 的整数"), + ("log_retention_days", 0, "log_retention_days 必须是大于等于 1 的整数"), + ("kb_chunk_size", 0, "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size"), + ("kb_chunk_size", -100, "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size"), + ("kb_chunk_overlap", -1, "kb_chunk_overlap/kb_child_chunk_overlap 必须是非负整数"), + ("kb_child_chunk_size", 0, "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size"), + ("kb_child_chunk_overlap", -1, "kb_chunk_overlap/kb_child_chunk_overlap 必须是非负整数"), + ("kb_embedding_batch_size", 0, "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size"), + ( + "kb_embedding_batch_size", + -1, + "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size", + ), + ("chat_vector_weight", -1.0, "chat_vector_weight/chat_keyword_weight 必须在 0 到 1 之间"), + ("chat_vector_weight", 5.0, "chat_vector_weight/chat_keyword_weight 必须在 0 到 1 之间"), + ("chat_keyword_weight", 2.0, "chat_vector_weight/chat_keyword_weight 必须在 0 到 1 之间"), ], ) -def test_numeric_settings_reject_illegal_values(field_name, bad_value): +def test_numeric_settings_reject_illegal_values(field_name, bad_value, expected_message): """数值字段的非法值必须在加载期报错,而不是拖到分片/检索期。 ``kb_chunk_size=0`` 此前会一路通过配置校验,直到 langchain 在分片阶段 才抛 ``chunk_size must be > 0``;负权重会反向加成分数。UI 层 (``src/ui/config_menu.py``)已有 0..1 校验,TOML/env 路径此前没有。 + + 断言用**该 validator 独有的消息片段**,而不是 ``match=field_name``:跨字段 + 的 ``model_validator`` 报错文本里也会出现 ``kb_chunk_size`` 等字段名(例如 + 「kb_chunk_overlap 必须小于 kb_chunk_size。」),用字段名匹配会让这些用例在 + 被保护的 validator 被删除后依然变绿(假绿)。 """ - with pytest.raises(ValidationError, match=field_name): + with pytest.raises(ValidationError, match=expected_message): Settings(**{field_name: bad_value}) @@ -113,11 +122,95 @@ def test_chunk_overlap_must_be_strictly_smaller_than_chunk_size(): Settings(kb_child_chunk_size=50, kb_child_chunk_overlap=50) +@pytest.mark.parametrize( + ("field_name", "bad_value"), + [ + ("chat_top_k", 10**9), + ("chat_top_k", 10**18), + ("retrieval_candidate_multiplier", 10**9), + ("retrieval_candidate_multiplier", 10**18), + ], +) +def test_retrieval_sizes_reject_unbounded_values(field_name, bad_value): + """检索规模必须有上界。 + + ``effective_top_k = chat_top_k * retrieval_candidate_multiplier`` 直通 + ``faiss_index.search()``,FAISS 不报错也不截断:实测 ``chat_top_k=10**9`` + 会被接受,单次查询真实分配约 3.6 GB 数组、RSS 涨 10.3 GB 并挂起 8.4 秒。 + """ + with pytest.raises(ValidationError, match=field_name): + Settings(**{field_name: bad_value}) + + +@pytest.mark.parametrize( + "field_name", + [ + "log_retention_days", + "kb_chunk_size", + "kb_child_chunk_size", + "kb_embedding_batch_size", + "kb_chunk_overlap", + "kb_child_chunk_overlap", + "chat_top_k", + "retrieval_candidate_multiplier", + "chat_vector_weight", + "chat_keyword_weight", + "chat_score_threshold", + "chat_temperature", + ], +) +def test_int_and_float_settings_reject_bool(field_name): + """``True``/``False`` 不得被当作 1/0 静默接受。 + + Python 里 ``bool`` 是 ``int`` 的子类,不做 ``isinstance(value, bool)`` 短路 + 的话 ``Settings(chat_top_k=True)`` 会静默变成 ``1``。这类配置错误应显式报错。 + """ + with pytest.raises(ValidationError, match=field_name): + Settings(**{field_name: True}) + + +def test_child_chunk_size_cannot_exceed_parent_chunk_size(): + """子分片不得大于父分片,否则层级结构静默退化为一层。 + + 实测 ``kb_chunk_size=300, kb_child_chunk_size=1500`` 被接受后,父块数从 2 + 涨到 10(每个父块只产出 1 个子块),「先粗后细」的父子检索意图失效。 + """ + with pytest.raises(ValidationError, match="kb_child_chunk_size"): + Settings(kb_chunk_size=300, kb_child_chunk_size=1500) + with pytest.raises(ValidationError, match="kb_child_chunk_size"): + Settings(kb_chunk_size=100, kb_child_chunk_size=100) + # 相等也应拒绝:与 overlap parent`` 一样退化为一层。 + """ settings = Settings( log_retention_days=1, - kb_chunk_size=1, + kb_chunk_size=2, kb_chunk_overlap=0, kb_child_chunk_size=1, kb_child_chunk_overlap=0, @@ -126,6 +219,7 @@ def test_numeric_settings_accept_legal_boundary_values(): chat_keyword_weight=1.0, ) assert settings.kb_chunk_overlap == 0 + assert settings.kb_child_chunk_size == 1 assert settings.chat_vector_weight == 0.0 assert settings.chat_keyword_weight == 1.0 From 1f9be3d9f03e93cf8e23b5e17b39f3e12eb9b739 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 21:14:09 +0800 Subject: [PATCH 28/31] =?UTF-8?q?fix:=20=E6=94=B6=E7=B4=A7=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E5=BF=AB=E7=85=A7=E8=BE=B9=E7=95=8C=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E5=88=86=E7=89=87=E5=A4=B1=E6=95=88=E4=B8=8E?= =?UTF-8?q?=20BM25=20=E9=9D=99=E9=BB=98=E7=A9=BA=E7=BB=93=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第三轮审查复现的问题集中在一类模式:**校验只做一半,非法值在离原因很远的地方才炸**。 逐项实测后修复如下。 分片(数据完整性,最严重) - `chunk_size` 在「分隔符耗尽」时完全失效:LangChain 的 `RecursiveCharacterTextSplitter._split_text` 走到 `if not new_separators: final_chunks.append(s)` 是原样追加、不再切分,而默认配置 `kb_splitter_separators = ["###"]` 只有一项匹配项,`new_separators` 直接为空。 实测 `chunk_size=1500` 时 3600 token 的纯列表文本产出单个 3599 token 块;对真实 知识库 39 个文件扫描,**34 个**产出超限块(子块上限 300,实测 4595)。现在分隔符 表统一以 `""` 收尾;含 `###` 的文档切分结果不变(实测 3 块/最大 252 字符)。 - `_strip_leading_punctuation` 直接删除开头句号。分隔符本身是句号时该句号被永久 丢弃(实测 843 字符分成 15 块后只剩 829,丢失 1.66%);整篇以句号开头更会产出 0 个块。改为替换为等长空格,信息一字不丢。 配置校验 - `chat_score_threshold=10**400` 抛的是**裸 `OverflowError`**(`float(10**400)`), 它不是 `ValidationError`,会穿透 `get_settings` 的 `except` 变成未脱敏 traceback。 范围判断移到转 float 之前。 - `log_retention_days` / `kb_chunk_size` / `kb_child_chunk_size` / `kb_embedding_batch_size` 原先只有下界,实测 `10**400` 全部被接受;补上界 (3650 / 1_000_000)。 - `_coerce_number` 的 `int()`/`float()` 会接受 `"1_0"`、`"+7"`、`"0x10"` (`int("1_0") == 10`、`float("1_0") == 10.0`),把写错的配置静默读成另一个数; 改为先做十进制形状校验。 - 列表类 env 字段(如 `KB_SPLITTER_SEPARATORS='###'`)抛的是 pydantic-settings 的 `SettingsError`,它继承 `ValueError` 但**不是** `ValidationError`,原 handler 接不住; 现在转成一行脱敏消息。 `SessionConfig` 缺校验(运行期入口) - `Settings` 只在启动时校验一次,而 UI 与库调用方经 `__setitem__` 写入的 `SessionConfig` **完全不校验**。实测 `chat_config["top_k"] = 10**9` 一路走到 `faiss_index.search()`(实测 `10**6` 就已按满槽位分配并耗时 1.56s);同时接受 `vector_weight=-5`、`retrieval_method='totally-invalid'`、`score_threshold=None`。 为 9 个标量字段补上校验器注册表,并加结构断言防止漏登记(漏登记会静默回落为 无校验,且不会让任何行为测试变红)。UI 两处输入同步补上界。 快照完整性 - `validate_snapshot_dir` 原先只查文件**存在性**。实测 `documents=3` 而 `embeddings=(2,4)`(`ntotal=2`)的 store 能正常落盘、通过校验、加载后零报错, 之后 `semantic_search` 拿 `indices` 索引 `self.documents` 才越界。现在校验 chunks / embeddings / stats.json 三者行数自洽,并把截断文件的解析失败 (原先裸 `EOFError`)归类为「快照不自洽」。 BM25 - `keyword_search` 的 `if score <= 0: break` 分不清「该词无判别力」与「0 分文档 排在前面把正分结果截断」。rank_bm25 的 idf 只对 `< 0` 做 epsilon 浮动,某个词 **恰好**出现在一半文档里(`n == N/2`)时 idf 恰为 0,该词分数全为 0 并静默返回空。 改为先看全局最高分,再按分数降序收集、0 分只跳过不终止。 - `load_snapshot` 从 `lexical.index` 读回 `[]` 时 `BM25Okapi` 在 `avgdl = num_doc / corpus_size` 处 `ZeroDivisionError`;空语料改为返回 None。 嵌入向量 - `_as_float32_matrix` 缺有限性校验:NaN/inf 被接受并写入 FAISS(`ntotal` 正常 增加),直到查询时才以 3.4e38 的哨兵距离暴露,届时已无法定位坏文档; `1e300` 还会先触发 `RuntimeWarning: overflow encountered in cast` 静默溢出。 对齐 `retrieval_service` 既有的 `math.isfinite` 校验。 界面 - `_initialize_vector_store` 无条件打印「知识快照加载成功。」——没有快照时 store 为空(`documents=0`、`faiss_index=None`),用户会先看到成功再看到永远是 「无相关文档」,把构建缺失误诊成检索质量差。改为按实际分块数区分提示。 测试 - 新增 72 条回归断言(1154 → 1226),全部红绿验证:临时回退对应源码后必须变红。 - 修正 3 条假绿/过时断言:`test_recursive_text_splitter` 原先要求恰好 2 块,而第 1 块长 22 字符已超过 `chunk_size=20`——它把「chunk_size 失效」当成了期望行为, 现改为断言每块不超限且内容并集覆盖原文;`log_retention_days` 断言文案随上界更新; factory 测试的占位空文件改为最小可解析快照(需配合新的行数校验)。 质量门(全部实跑):pytest 1226 passed、ruff format/check、mypy 49 files、 bandit -ll、compileall、uv lock --check、git diff --check。 --- src/chat/core.py | 14 +- src/etl/splitters/recursive_text_splitter.py | 43 +++++- src/retrieval/snapshot_repository.py | 72 ++++++++++ src/retrieval/vdb/faiss_store.py | 39 +++++- src/runtime/contracts.py | 118 +++++++++++++++- src/services/embedding_service.py | 9 ++ src/ui/config_menu.py | 18 ++- src/utils/config.py | 83 +++++++++-- tests/etl/test_hierarchical_splitter.py | 130 ++++++++++++++++++ tests/etl/test_pipeline.py | 30 +++- tests/retrieval/vdb/test_factory.py | 21 ++- tests/retrieval/vdb/test_faiss_index_text.py | 86 ++++++++++++ .../retrieval/vdb/test_snapshot_repository.py | 122 ++++++++++++++++ tests/runtime/__init__.py | 0 tests/runtime/test_contracts.py | 117 ++++++++++++++++ tests/services/test_embedding_service.py | 34 +++++ tests/test_config.py | 102 +++++++++++++- 17 files changed, 995 insertions(+), 43 deletions(-) create mode 100644 tests/runtime/__init__.py create mode 100644 tests/runtime/test_contracts.py diff --git a/src/chat/core.py b/src/chat/core.py index e073d18..907fd12 100644 --- a/src/chat/core.py +++ b/src/chat/core.py @@ -69,7 +69,19 @@ def _initialize_vector_store(self): vector_store=self.vector_store, embedding_service=EmbeddingService(self.run_config), ) - self.console.print("[green]知识快照加载成功。[/green]") + # 不能无条件报「加载成功」:没有任何快照/旧版 pkl 时 store 是空的 + # (documents=0、faiss_index=None),用户会先看到成功再看到永远是 + # 「无相关文档」,把构建缺失误诊成检索质量差。 + chunk_count = len(getattr(self.vector_store, "documents", []) or []) + if chunk_count: + self.console.print(f"[green]知识快照加载成功,共 {chunk_count} 个分块。[/green]") + else: + self.console.print( + "[bold yellow]未找到可用的知识快照,检索将始终返回空结果。[/bold yellow]" + ) + self.console.print( + "[yellow]请先在主菜单执行「知识库文档向量化处理」构建快照。[/yellow]" + ) def reload_llm(self) -> bool: previous_model = self.llm_model diff --git a/src/etl/splitters/recursive_text_splitter.py b/src/etl/splitters/recursive_text_splitter.py index 4786e90..e699f3d 100644 --- a/src/etl/splitters/recursive_text_splitter.py +++ b/src/etl/splitters/recursive_text_splitter.py @@ -66,6 +66,28 @@ def _get_length_function(self) -> Callable[[str], int]: return lambda x: len(self._encoder.encode(x)) return len + @staticmethod + def _with_fallback_separator(separators: list[str]) -> list[str]: + """确保分隔符表以 ``""`` 结尾,让 ``chunk_size`` 在退化输入下仍然生效。 + + ``RecursiveCharacterTextSplitter._split_text`` 在递归到最后一个分隔符时 + 走的是 ``if not new_separators: final_chunks.append(s)`` —— **原样追加**, + 不再按 ``chunk_size`` 切。因此分隔符表里只要没排到 ``""``(唯一能逐字符 + 硬切的那一项),「分隔符耗尽」的文本就会整段留下。 + + 默认配置 ``kb_splitter_separators = ["###"]`` 恰好命中这一点: + ``###`` 是唯一的匹配项,`new_separators` 直接为空,于是**任何不含 + ``###`` 的文档整篇变成一个块**。实测 ``chunk_size=1500`` 时,3600 token + 的纯列表文本产出单个 3599 token 的块;对真实知识库 39 个文件扫描,34 个 + 文件产出超限块(最坏:子块上限 300,实测 4595)。 + + 兜底只在「前面所有分隔符都不匹配」时才被用到:实测含 ``###`` 的文档在 + ``["###"]`` 与 ``["###", ""]`` 下块数/最大块完全一致(3 / 252)。 + """ + if "" in separators: + return list(separators) + return [*separators, ""] + def _init_splitter(self): """根据当前配置初始化内部分割器。""" current_settings = get_settings() @@ -74,7 +96,7 @@ def _init_splitter(self): chunk_overlap=self.parent_chunk_overlap if self.parent_chunk_overlap is not None else current_settings.kb_chunk_overlap, - separators=current_settings.kb_splitter_separators, + separators=self._with_fallback_separator(current_settings.kb_splitter_separators), length_function=self._get_length_function(), is_separator_regex=False, ) @@ -97,15 +119,28 @@ def _build_child_splitter(self) -> RecursiveCharacterTextSplitter: if self.child_chunk_overlap is not None else current_settings.kb_child_chunk_overlap ), - separators=current_settings.kb_splitter_separators, + separators=self._with_fallback_separator(current_settings.kb_splitter_separators), length_function=self._get_length_function(), is_separator_regex=False, ) @staticmethod def _strip_leading_punctuation(text: str) -> str: - """去掉分片开头的句号类标点。""" - return re.sub(r"^[\s.。]+", "", text).strip() + r"""去掉分片开头因切分残留的句号类标点,但不丢弃信息。 + + 旧实现是 ``re.sub(r"^[\s.。]+", "", text).strip()`` —— 直接**删除** + 开头连续的空白与句号。当分隔符本身就是句号(``kb_splitter_separators`` + 配成 ``。``)时,LangChain 的 ``keep_separator=True`` 会把 + 上一句的句号留在下一个分片开头,于是那个句号被永久丢弃:实测 + ``chunk_size=40, separators=["。"]`` 下原始 843 字符分成 15 块后只剩 + 829 字符,丢失 1.66%。最坏情况整篇都是句号分隔符时,全部分片都可能被判为空 + 并 ``continue`` 掉,产出 0 个块。 + + 现在只规范化边界空白:把开头的句号替换为等长空格再 strip,句中信息 + 一字不改,也不会再产生「内容全被删掉因此该块被跳过」的路径。 + """ + text = re.sub(r"^[\s.。]+", lambda match: " " * len(match.group()), text) + return text.strip() def _split_standard_documents(self, documents: list[Document]) -> list[Document]: """标准单层分片。""" diff --git a/src/retrieval/snapshot_repository.py b/src/retrieval/snapshot_repository.py index 4fa9774..f543a5d 100644 --- a/src/retrieval/snapshot_repository.py +++ b/src/retrieval/snapshot_repository.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import pickle # nosec B403 - 仅用于读取应用自管的本地快照文件 import shutil import tomllib import uuid @@ -68,6 +70,15 @@ def load_manifest(self, snapshot_dir: Path) -> KnowledgeSnapshotManifest: return KnowledgeSnapshotManifest.from_mapping(data) def validate_snapshot_dir(self, snapshot_dir: Path) -> None: + """校验快照文件齐全**且三个数据源的行数自洽**。 + + 只查文件存在性是不够的:实测手工构造 ``documents=3`` 而 + ``embeddings=(2,4)``(``faiss_index.ntotal=2``)的 store, + ``save_snapshot`` 正常落盘、本方法通过、加载后也不报错——不一致被 + 完整持久化。之后 ``semantic_search`` 拿 ``indices`` 去索引 + ``self.documents`` 会越界,而报错现场离真正的原因(写入时就不一致) + 很远。这里在加载/切换快照的入口就把三者对齐。 + """ required_files = [ snapshot_dir / "manifest.toml", snapshot_dir / "chunks.pkl", @@ -79,3 +90,64 @@ def validate_snapshot_dir(self, snapshot_dir: Path) -> None: missing_files = [str(path.name) for path in required_files if not path.exists()] if missing_files: raise FileNotFoundError(f"知识快照不完整,缺少文件: {', '.join(missing_files)}") + + self._validate_snapshot_row_counts(snapshot_dir) + + @staticmethod + def _validate_snapshot_row_counts(snapshot_dir: Path) -> None: + """确认 chunks / embeddings / 向量索引三者的行数一致。 + + 读取全部走本地受信快照目录(与 ``FaissStore.load_snapshot`` 同一 + 前提),且只取形状不取内容语义。 + """ + import numpy as np + + # 解析失败要转成「快照不自洽」的 ValueError:这里是加载入口,抛裸的 + # EOFError/UnpicklingError 会让调用方看到与真实原因无关的异常类型 + # (测试里就复现过:占位空文件 chunks.pkl -> EOFError)。 + try: + with (snapshot_dir / "chunks.pkl").open("rb") as file: + chunks = pickle.load(file) # nosec B301 - 应用自管的快照目录 + except Exception as exc: + raise ValueError( + f"知识快照不自洽:chunks.pkl 无法解析({type(exc).__name__})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc + + try: + embeddings = np.load(snapshot_dir / "embeddings.npy") + except Exception as exc: + raise ValueError( + f"知识快照不自洽:embeddings.npy 无法解析({type(exc).__name__})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc + + chunk_count = len(chunks) if chunks is not None else 0 + if embeddings.ndim != 2: + raise ValueError(f"知识快照的 embeddings.npy 不是二维数组: ndim={embeddings.ndim}。") + embedding_rows = int(embeddings.shape[0]) + + if chunk_count != embedding_rows: + raise ValueError( + "知识快照不自洽:分块数与向量数不一致。" + f" chunks.pkl={chunk_count},embeddings.npy={embedding_rows}。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) + + stats_path = snapshot_dir / "stats.json" + try: + with stats_path.open("rb") as file: + stats = json.load(file) + except Exception as exc: + raise ValueError( + f"知识快照不自洽:stats.json 无法解析({type(exc).__name__})。" + " 快照可能在写入或复制过程中被截断,请重建。" + ) from exc + if not isinstance(stats, dict): + raise ValueError("知识快照不自洽:stats.json 的顶层不是对象。") + declared = stats.get("chunk_count") + if declared is not None and int(declared) != chunk_count: + raise ValueError( + "知识快照不自洽:stats.json 的 chunk_count 与实际分块数不一致。" + f" stats.json={declared},chunks.pkl={chunk_count}。" + ) diff --git a/src/retrieval/vdb/faiss_store.py b/src/retrieval/vdb/faiss_store.py index f14d67f..03b888c 100644 --- a/src/retrieval/vdb/faiss_store.py +++ b/src/retrieval/vdb/faiss_store.py @@ -67,12 +67,25 @@ def _build_index_text(document: dict[str, Any]) -> str: return f"{source_hint}\n{page_content}" return page_content + @staticmethod + def _build_bm25_index(tokenized_docs: list[list[str]]) -> BM25Okapi | None: + """构造 BM25 索引;空语料返回 None 而不是让 rank_bm25 除零。 + + ``BM25._initialize`` 计算 ``avgdl = num_doc / self.corpus_size``, + 语料为空时直接 ``ZeroDivisionError``。``load_snapshot`` 会从 + ``lexical.index`` 读回 ``[]``(空快照或手工构造的目录),旧实现 + 在此崩溃且不报「快照为空」这个真实原因。 + """ + if not tokenized_docs: + return None + return BM25Okapi(tokenized_docs) + def _rebuild_indices(self) -> None: if self.documents: self._tokenized_docs_cache = [ list(jieba.cut(self._build_index_text(doc))) for doc in self.documents ] - self.bm25_index = BM25Okapi(self._tokenized_docs_cache) + self.bm25_index = self._build_bm25_index(self._tokenized_docs_cache) else: self._tokenized_docs_cache = [] self.bm25_index = None @@ -178,11 +191,31 @@ def keyword_search(self, query_text: str, top_k: int = 5) -> list[dict[str, Any] doc_scores = self.bm25_index.get_scores(tokenized_query) top_indices = np.argsort(doc_scores)[::-1] + # 分数可能整片为 0,而不是「最高分是 0」。rank_bm25 的 idf 是 + # ``log(N - n + 0.5) - log(n + 0.5)``,且只对 ``idf < 0`` 做 epsilon + # 浮动——当某个词**恰好**出现在一半文档里(``n == N/2``)时 idf 恰为 + # 0,不属于「负」因此不被浮动,该词在所有文档上的分数就全是 0。 + # 查询词根本不在语料里时同样全 0。 + # + # 此时旧实现(``if score <= 0: break``)静默返回空列表,且区分不了 + # 下面两种完全不同的情况: + # (a) 该词无判别力 / 语料里没有这个词 —— 空结果本身说得通; + # (b) 排名里混着正分文档,只是 0 分文档排在前面把循环提前 break —— + # 这是丢结果。 + # 因此先看全局最高分:最高分 <= 0 直接返回空(并在 debug 里说明原因), + # 否则按分数降序收集,遇 0 分只跳过该条、不终止。 + if doc_scores.size == 0 or float(doc_scores[top_indices[0]]) <= 0: + logger.debug( + "关键词检索无有效分数(查询词可能不在语料中,或恰好出现在一半文档里" + " 导致 BM25 idf 为 0),返回空结果。" + ) + return [] + results: list[dict[str, Any]] = [] for index in top_indices: score = float(doc_scores[index]) if score <= 0: - break + continue document = deepcopy(self.documents[index]) document["score"] = score results.append(document) @@ -275,7 +308,7 @@ def load_snapshot(self, snapshot_dir: str): if lexical_path.exists(): with lexical_path.open("rb") as file: self._tokenized_docs_cache = pickle.load(file) # nosec B301 - self.bm25_index = BM25Okapi(self._tokenized_docs_cache) + self.bm25_index = self._build_bm25_index(self._tokenized_docs_cache) else: self._rebuild_indices() self._normalize_loaded_documents() diff --git a/src/runtime/contracts.py b/src/runtime/contracts.py index 808cbfc..422bb52 100644 --- a/src/runtime/contracts.py +++ b/src/runtime/contracts.py @@ -1,13 +1,20 @@ from __future__ import annotations -from collections.abc import Iterator, MutableMapping +import math +from collections.abc import Callable, Iterator, MutableMapping from copy import deepcopy from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, ClassVar -from src.utils.config import ModelDetail, RetrievalMethod, Settings +from src.utils.config import ( + _MAX_RETRIEVAL_MULTIPLIER, + _MAX_RETRIEVAL_TOP_K, + ModelDetail, + RetrievalMethod, + Settings, +) SCHEMA_VERSION = "2" @@ -69,11 +76,99 @@ def __init__( self.rerank_configurations = rerank_configurations self.chat_temperature = chat_temperature + # 可经 ``__setitem__`` 写入的字段及其校验器。``__init__`` 信任调用方 + # (``build_session_config`` 从已校验的 ``Settings`` 取值,合法), + # 但 UI 与库调用方走 ``__setitem__``,必须逐字段校验。 + # + # 这里不是重复 `Settings` 的工作:`Settings` 只在启动时校验一次,而 + # `SessionConfig` 是运行期可变的独立入口。修 `top_k` 上界之前实测 + # `chat_config["top_k"] = 10**9` 能一路走到 `faiss_index.search()`, + # FAISS 既不报错也不截断,按 10 亿条分配。注册表在类体外填充(类体内直接 + # 引用 `cls._validate_x` 会在定义期拿不到绑定方法)。 + _VALIDATORS: ClassVar[dict[str, Callable[[Any], Any]]] = {} + def __getitem__(self, key: str) -> Any: return getattr(self, key) def __setitem__(self, key: str, value: Any) -> None: - setattr(self, key, value) + if key not in self.to_dict(): + raise KeyError(f"SessionConfig 没有字段 {key!r}。") + validator = self._VALIDATORS.get(key) + setattr(self, key, validator(value) if validator is not None else value) + + @staticmethod + def _require_bounded_int(value: Any, label: str, upper: int) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{label} 必须是整数,但得到 {value!r}。") + if not 1 <= value <= upper: + raise ValueError(f"{label} 必须是 1 到 {upper} 之间的整数,但得到 {value}。") + return value + + @classmethod + def _validate_top_k(cls, value: Any) -> int: + return cls._require_bounded_int(value, "top_k", _MAX_RETRIEVAL_TOP_K) + + @classmethod + def _validate_candidate_multiplier(cls, value: Any) -> int: + return cls._require_bounded_int( + value, "retrieval_candidate_multiplier", _MAX_RETRIEVAL_MULTIPLIER + ) + + @staticmethod + def _validate_unit_weight(value: Any, label: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{label} 必须是数值,但得到 {value!r}。") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{label} 必须是有限数值,但得到 {value!r}。") + if not 0.0 <= number <= 1.0: + raise ValueError(f"{label} 必须在 0.0 到 1.0 之间,但得到 {value!r}。") + return number + + @classmethod + def _validate_vector_weight(cls, value: Any) -> float: + return cls._validate_unit_weight(value, "vector_weight") + + @classmethod + def _validate_keyword_weight(cls, value: Any) -> float: + return cls._validate_unit_weight(value, "keyword_weight") + + @classmethod + def _validate_score_threshold(cls, value: Any) -> float: + return cls._validate_unit_weight(value, "score_threshold") + + @classmethod + def _validate_retrieval_method(cls, value: Any) -> RetrievalMethod: + if isinstance(value, RetrievalMethod): + return value + try: + return RetrievalMethod(value) + except ValueError as exc: + allowed = ", ".join(member.value for member in RetrievalMethod) + raise ValueError(f"retrieval_method 必须是 {allowed} 之一,但得到 {value!r}。") from exc + + @staticmethod + def _validate_fusion_strategy(value: Any) -> str: + if value not in {"rrf", "weighted"}: + raise ValueError( + f"hybrid_fusion_strategy 必须是 'rrf' 或 'weighted',但得到 {value!r}。" + ) + return str(value) + + @staticmethod + def _validate_rerank_enabled(value: Any) -> bool: + if not isinstance(value, bool): + raise ValueError(f"rerank_enabled 必须是布尔值,但得到 {value!r}。") + return value + + @staticmethod + def _validate_chat_temperature(value: Any) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"chat_temperature 必须是数值,但得到 {value!r}。") + number = float(value) + if not math.isfinite(number) or number < 0.0: + raise ValueError(f"chat_temperature 必须是非负有限数值,但得到 {value!r}。") + return number def __delitem__(self, key: str) -> None: raise TypeError("SessionConfig 不支持删除字段。") @@ -102,6 +197,21 @@ def to_dict(self) -> dict[str, Any]: } +# 每个可经 ``__setitem__`` 写入的字段都要在这里登记;未登记的字段(模型配置字典 +# 等)按原样写入,由使用方负责其内部结构。 +SessionConfig._VALIDATORS = { + "retrieval_method": SessionConfig._validate_retrieval_method, + "vector_weight": SessionConfig._validate_vector_weight, + "keyword_weight": SessionConfig._validate_keyword_weight, + "hybrid_fusion_strategy": SessionConfig._validate_fusion_strategy, + "retrieval_candidate_multiplier": SessionConfig._validate_candidate_multiplier, + "rerank_enabled": SessionConfig._validate_rerank_enabled, + "top_k": SessionConfig._validate_top_k, + "score_threshold": SessionConfig._validate_score_threshold, + "chat_temperature": SessionConfig._validate_chat_temperature, +} + + @dataclass(frozen=True) class KnowledgeSnapshotManifest: schema_version: str diff --git a/src/services/embedding_service.py b/src/services/embedding_service.py index d876e7b..320f7ba 100644 --- a/src/services/embedding_service.py +++ b/src/services/embedding_service.py @@ -93,4 +93,13 @@ def _as_float32_matrix(embeddings: Any, expected_rows: int | None = None) -> np. "EmbeddingService 收到的向量数量与输入文本数量不一致。" f" 输入 {expected_rows} 条,返回 {matrix.shape[0]} 条。" ) + # 有限性是硬要求,不是洁癖:FAISS 会把 NaN 向量照单收下(ntotal 正常增加), + # 直到查询时才以 3.4e38 的哨兵距离暴露出来,而那时「哪个文档坏了」已经无法 + # 定位;inf 同理。另外 ``np.array(..., dtype=np.float32)`` 对超出 float32 + # 范围的值只发 RuntimeWarning 并静默溢出成 inf,所以在 cast 之后检查。 + if not np.isfinite(matrix).all(): + raise ValueError( + "EmbeddingService 收到的向量包含非有限值(NaN 或无穷大);" + "请检查 embedding provider 的返回内容。" + ) return matrix diff --git a/src/ui/config_menu.py b/src/ui/config_menu.py index 1f32ef8..60f1f21 100644 --- a/src/ui/config_menu.py +++ b/src/ui/config_menu.py @@ -4,7 +4,11 @@ from rich.console import Console from rich.panel import Panel -from ..utils.config import RetrievalMethod +from ..utils.config import ( + _MAX_RETRIEVAL_MULTIPLIER, + _MAX_RETRIEVAL_TOP_K, + RetrievalMethod, +) from ..utils.log_manager import get_module_logger # 导入日志管理器 from .display_utils import display_chat_config @@ -67,9 +71,11 @@ def edit_retrieval_params(chat_config: dict[str, Any]) -> None: logger.info(f"检索模式已更新为: {new_method}") elif choice == "top_k": + # 上界与 SessionConfig/Settings 的守卫一致:此处不挡,非法值会一路 + # 走到 faiss_index.search(),FAISS 既不报错也不截断。 new_top_k = questionary.text( - f"输入新的Top K值 (当前: {current_top_k}):", - validate=lambda text: text.isdigit() and int(text) > 0, + f"输入新的Top K值 (1-{_MAX_RETRIEVAL_TOP_K}, 当前: {current_top_k}):", + validate=lambda text: text.isdigit() and 1 <= int(text) <= _MAX_RETRIEVAL_TOP_K, default=str(current_top_k), ).ask() if new_top_k: @@ -136,8 +142,10 @@ def is_float_between_0_and_1(text): elif choice == "candidate_multiplier": new_multiplier = questionary.text( - f"输入候选过量招募倍率 (当前: {current_candidate_multiplier}):", - validate=lambda text: text.isdigit() and int(text) >= 1, + f"输入候选过量招募倍率 (1-{_MAX_RETRIEVAL_MULTIPLIER}, 当前: {current_candidate_multiplier}):", + validate=lambda text: ( + text.isdigit() and 1 <= int(text) <= _MAX_RETRIEVAL_MULTIPLIER + ), default=str(current_candidate_multiplier), ).ask() if new_multiplier: diff --git a/src/utils/config.py b/src/utils/config.py index 3aee685..abe7bda 100644 --- a/src/utils/config.py +++ b/src/utils/config.py @@ -1,4 +1,6 @@ import functools +import math +import re import sys import tomllib import warnings @@ -21,6 +23,7 @@ PydanticBaseSettingsSource, SettingsConfigDict, ) +from pydantic_settings.exceptions import SettingsError from src.utils.security import redact_sensitive_text, validate_secret_free_options @@ -146,9 +149,34 @@ def normalize_protocol(cls, value: Any) -> ModelProtocol | None: # 也不截断。实测 ``chat_top_k=10**9`` 单次查询分配约 3.6 GB 数组、RSS 涨 # 10.3 GB、耗时 8.4 秒。10_000 远超任何真实场景(默认 top_k=5 × multiplier=3)。 _MAX_RETRIEVAL_TOP_K = 10_000 +# 日志保留天数与分片/批处理规模的上界。两者原来都无上界,实测 +# ``log_retention_days=10**400``、``kb_chunk_size=10**400`` 均被接受, +# 直到真正参与运算(日期减法、内存申请)才炸。 +_MAX_LOG_RETENTION_DAYS = 3650 +_MAX_KB_CHUNK_TOKENS = 1_000_000 _MAX_RETRIEVAL_MULTIPLIER = 100 +# 十进制数值字面量:可选负号、可选小数点、可选指数。不含下划线分组(``1_0``)、 +# 不含正号(``+7``)、不含十六进制(``0x10``)。Python 的 ``int()``/``float()`` +# 都会接受它们(``int("1_0") == 10``、``float("1_0") == 10.0``、 +# ``float("+7") == 7.0``),静默读出一个「看起来成功」的错值,因此这里先做形状 +# 校验再交给 ``float()`` 解析。 +_DECIMAL_LITERAL_PATTERN = re.compile(r"-?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?") + + +def _parse_int_literal(text: str) -> int | None: + """严格解析十进制整数字面量,不做科学计数法/下划线/正号的宽松放行。 + + 只接受 ``[0-9]+``(可带前导负号)。``"1_0"`` 被静默读成 10 这类安静的 + 成功正是本项目反复踩到的模式,所以宁愿在这里明确拒绝。 + """ + digits = text.removeprefix("-") + if digits and all(char in "0123456789" for char in digits): + return int(text) + return None + + def _coerce_number(value: Any, field_label: str) -> int | float: """把 ``mode="before"`` 收到的原始输入转成数值。 @@ -156,6 +184,11 @@ def _coerce_number(value: Any, field_label: str) -> int | float: ``.env``/TOML 来源是字符串(``"5"``),Python 调用方可能是 ``int``/``float``, 而 ``bool`` 是 ``int`` 的子类(``int(True) == 1``)必须显式拒绝,否则 ``Settings(chat_top_k=True)`` 会静默变成 ``1``。 + + 字符串只接受十进制数值字面量:``"1500"``、``"0.4"``、``"1e3"``。不接受 + ``"0x10"``(十六进制)与 ``"inf"``/``"nan"``(非有限值由各 validator 的 + ``math.isfinite`` 或上下界负责,但让 ``float()`` 接住它们会得到 + ``inf`` 这类「成功解析出的非法值」,不如直接在这里当解析失败处理)。 """ if isinstance(value, bool): raise ValueError(f"{field_label} 不能是布尔值 {value!r}。") @@ -163,13 +196,18 @@ def _coerce_number(value: Any, field_label: str) -> int | float: return value if isinstance(value, str): text = value.strip() + parsed_int = _parse_int_literal(text) + if parsed_int is not None: + return parsed_int + if not _DECIMAL_LITERAL_PATTERN.fullmatch(text): + raise ValueError(f"无法把 {field_label} 的 {value!r} 转换为数值。") try: - return int(text) - except ValueError: - try: - return float(text) - except ValueError as exc: - raise ValueError(f"无法把 {field_label} 的 {value!r} 转换为数值。") from exc + number = float(text) + except ValueError as exc: + raise ValueError(f"无法把 {field_label} 的 {value!r} 转换为数值。") from exc + if not math.isfinite(number): + raise ValueError(f"{field_label} 必须是有限数值,但得到 {value!r}。") + return number raise ValueError(f"{field_label} 必须是数值,但得到 {type(value).__name__}。") @@ -350,10 +388,15 @@ def validate_chat_top_k(cls, value: Any) -> int: @field_validator("chat_score_threshold", mode="before") @classmethod def validate_chat_score_threshold(cls, value: Any) -> float: - number = float(_coerce_number(value, "chat_score_threshold")) + number = _coerce_number(value, "chat_score_threshold") + # 先做范围判断再转 float:``float(10**400)`` 抛的是裸 ``OverflowError``, + # 它不是 ``ValidationError``,会穿透 ``get_settings`` 的 ``except`` 变成 + # 未脱敏的 traceback。范围判断对任意大的 int 都能给出正常结论。 + if not isinstance(number, (int, float)) or isinstance(number, bool): + raise ValueError(f"chat_score_threshold 必须是数值,但得到 {value!r}。") if not 0 <= number <= 1: - raise ValueError("chat_score_threshold 必须在 0 到 1 之间。") - return number + raise ValueError(f"chat_score_threshold 必须在 0 到 1 之间,但得到 {value!r}。") + return float(number) @field_validator("log_retention_days", mode="before") @classmethod @@ -362,8 +405,12 @@ def validate_log_retention_days(cls, value: Any) -> int: if isinstance(number, float) and not number.is_integer(): raise ValueError(f"log_retention_days 必须是整数,但得到 {number}。") number = int(number) - if number < 1: - raise ValueError("log_retention_days 必须是大于等于 1 的整数。") + # 上界不是洁癖:无上界时 10**400 会被接受(实测),而它接下来会被交给 + # 日期运算做 ``today - timedelta(days=N)``,直接 OverflowError。 + if not 1 <= number <= _MAX_LOG_RETENTION_DAYS: + raise ValueError( + f"log_retention_days 必须是 1 到 {_MAX_LOG_RETENTION_DAYS} 之间的整数。" + ) return number @field_validator( @@ -380,9 +427,13 @@ def validate_positive_sizes(cls, value: Any) -> int: "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size 必须是整数。" ) number = int(number) - if number < 1: + # 上界不是洁癖:无上界时 10**400 会被接受(实测),随后分片/批处理会把它 + # 当成真实规模去申请内存。tiktoken 编码前的分片上限取 100 万 token,比任何 + # 现实文档都宽,但仍挡住溢出量级。 + if not 1 <= number <= _MAX_KB_CHUNK_TOKENS: raise ValueError( - "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size 必须是大于等于 1 的整数。" + "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size" + f" 必须是 1 到 {_MAX_KB_CHUNK_TOKENS} 之间的整数。" ) return number @@ -810,6 +861,12 @@ def get_settings() -> Settings: return Settings() except ValidationError as exc: raise ValueError(_redacted_settings_error(exc)) from None + except SettingsError as exc: + # pydantic-settings 在解析复杂字段(list/dict)时抛的是 SettingsError, + # 它继承 ValueError 但**不是** ValidationError,因此上面那个分支接不住。 + # 实测 ``KB_SPLITTER_SEPARATORS='###'``(非 JSON 形态)走的正是这条路, + # 用户看到的是解释器默认 handler 打出的多屏 traceback,而不是脱敏消息。 + raise ValueError(f"配置解析失败 - {redact_sensitive_text(str(exc))}") from None def _redacted_settings_error(exc: ValidationError) -> str: diff --git a/tests/etl/test_hierarchical_splitter.py b/tests/etl/test_hierarchical_splitter.py index 81e9fc4..653445b 100644 --- a/tests/etl/test_hierarchical_splitter.py +++ b/tests/etl/test_hierarchical_splitter.py @@ -40,3 +40,133 @@ def test_hierarchical_splitter_builds_parent_child_metadata(monkeypatch): parent_content = next(iter(splitter.parent_documents.values()))["content"] assert isinstance(parent_content, str) and parent_content assert any(chunk.content != parent_content for chunk in chunks) + + +# ── 回归:chunk_size 在「分隔符耗尽」的退化输入下也必须生效 ── + + +def _splitter_with(mode="token", structure_mode="standard", **overrides): + """构造分片器并覆盖指定设置字段,返回 (splitter, settings, 原值快照)。""" + from src.utils.config import get_settings + + settings = get_settings() + original = {key: getattr(settings, key) for key in overrides} + for key, value in overrides.items(): + setattr(settings, key, value) + splitter = RecursiveTextSplitter(mode=mode, structure_mode=structure_mode) + return splitter, settings, original + + +def _restore(settings, original): + for key, value in original.items(): + setattr(settings, key, value) + + +def test_splitter_enforces_chunk_size_without_matching_separator(monkeypatch): + """默认分隔符 ``["###"]`` 下,不含 ``###`` 的文档不得整篇变成一个块。 + + ``RecursiveCharacterTextSplitter._split_text`` 在 ``not new_separators`` 时走 + ``final_chunks.append(s)``——**原样追加**,不再按 ``chunk_size`` 切。默认配置 + ``kb_splitter_separators = ["###"]`` 恰好命中:``###`` 是唯一匹配项, + ``new_separators`` 直接为空。实测修复前 ``chunk_size=1500`` 时 3600 token 的 + 纯列表文本产出单个 3599 token 块。 + """ + import tiktoken + + encoder = tiktoken.get_encoding("cl100k_base") + text = "- 项目说明文字\n" * 600 + assert len(encoder.encode(text)) > 1500, "前提:输入必须远超 chunk_size" + + splitter, settings, original = _splitter_with( + kb_chunk_size=1500, kb_chunk_overlap=0, kb_splitter_separators=["###"] + ) + try: + chunks = splitter.split([Document(content=text, metadata={"source": "list.md"})]) + finally: + _restore(settings, original) + + assert len(chunks) > 1, "分隔符耗尽时仍应被硬切分,而不是整篇一个块" + oversize = [ + len(encoder.encode(chunk.content)) + for chunk in chunks + if len(encoder.encode(chunk.content)) > 1500 + ] + assert not oversize, f"以下分片超过 chunk_size=1500: {oversize}" + + +def test_splitter_output_is_unchanged_when_separator_matches(monkeypatch): + """兜底分隔符只在「前面都不匹配」时生效,不得改变正常文档的切分结果。 + + 含 ``###`` 的文档在 ``["###"]`` 与 ``["###", ""]`` 下块数/最大块应完全一致 + (实测 3 / 252)。 + """ + import tiktoken + + encoder = tiktoken.get_encoding("cl100k_base") + text = "\n\n".join(f"### 小节{index}\n" + "内容。" * 60 for index in range(6)) + + splitter, settings, original = _splitter_with( + kb_chunk_size=300, kb_chunk_overlap=30, kb_splitter_separators=["###"] + ) + try: + chunks = splitter.split([Document(content=text, metadata={"source": "doc.md"})]) + finally: + _restore(settings, original) + + assert len(chunks) == 3 + assert max(len(encoder.encode(chunk.content)) for chunk in chunks) == 252 + + +def test_strip_leading_punctuation_does_not_delete_content(): + """开头标点只能被规范化,不能被删除。 + + 旧实现 ``re.sub(r"^[\\s.。]+", "", text).strip()`` 直接删掉开头连续的句号; + 当分隔符本身就是句号时,LangChain 的 ``keep_separator=True`` 会把上一句的句号 + 留在下一个分片开头,于是那个句号被永久丢弃。 + """ + splitter = RecursiveTextSplitter(mode="char") + + assert splitter._strip_leading_punctuation("。第7句内容") == "第7句内容" + assert splitter._strip_leading_punctuation("。。开头两句") == "开头两句" + assert splitter._strip_leading_punctuation(" \n 正常文本") == "正常文本" + assert splitter._strip_leading_punctuation("开头就是正文") == "开头就是正文" + + +def test_splitter_keeps_all_content_when_punctuation_is_the_separator(monkeypatch): + """分隔符是句号时不得丢掉任何正文,也不得产出 0 个块。 + + 实测修复前 ``chunk_size=40, separators=["。"]``:原始 843 字符分成 15 块后只剩 + 829 字符,丢失 1.66%;整篇以句号开头的输入更会被判空并 ``continue``,产出 0 块。 + """ + body = "。".join(f"第{index}句内容" for index in range(1, 61)) + + splitter, settings, original = _splitter_with( + kb_chunk_size=40, kb_chunk_overlap=0, kb_splitter_separators=["。"] + ) + try: + chunks = splitter.split([Document(content=body, metadata={"source": "body.md"})]) + finally: + _restore(settings, original) + + assert chunks, "不得产出 0 个块" + covered = "".join(chunk.content for chunk in chunks) + for char in body: + if char == "。": + continue + assert char in covered, f"字符 {char!r} 在所有分片中都找不到" + + +def test_splitter_keeps_punctuation_only_document(monkeypatch): + """整篇以句号开头/结尾时仍必须产出分片(旧实现全部判空跳过)。""" + text = "。" * 50 + "正文" + "。" * 50 + + splitter, settings, original = _splitter_with( + kb_chunk_size=40, kb_chunk_overlap=0, kb_splitter_separators=["。"] + ) + try: + chunks = splitter.split([Document(content=text, metadata={"source": "dots.md"})]) + finally: + _restore(settings, original) + + assert chunks + assert any("正文" in chunk.content for chunk in chunks) diff --git a/tests/etl/test_pipeline.py b/tests/etl/test_pipeline.py index 3e8a935..5fae5e8 100644 --- a/tests/etl/test_pipeline.py +++ b/tests/etl/test_pipeline.py @@ -136,20 +136,36 @@ def test_recursive_text_splitter(): splitter = RecursiveTextSplitter(mode="char") # 实例化时强制用 char 模式 # 使用一个更长的文本来测试分割和重叠 - # 调整文本内容,使其在 chunk_size=20, chunk_overlap=5 的情况下能被分割成两部分 + # 注意断言的是「每个块都不超过 chunk_size」而不是某个固定的块数: + # 旧断言要求恰好 2 块,但第 1 块长 22 字符、已经超过 chunk_size=20—— + # 也就是说这条断言把「chunk_size 失效」当成了期望行为。分隔符表补上 "" + # 兜底后长句会被真正切开,块数随之变化。 long_text = "这是一个非常长的句子,需要被正确地切分开来。\n\n这是第二部分。" doc = Document(content=long_text, metadata={"source": "test.txt"}) # split 方法期望 List[Document] 作为输入 split_docs = splitter.split([doc]) - assert len(split_docs) == 2, "文本应该被分割成两部分" - # 验证第一部分的内容 - assert split_docs[0].content == "这是一个非常长的句子,需要被正确地切分开来。" - # 验证第二部分的内容 - assert split_docs[1].content == "这是第二部分。" + assert split_docs, "分割结果不应为空" + for chunk in split_docs: + assert len(chunk.content) <= test_chunk_size, ( + f"分片长度 {len(chunk.content)} 超过 chunk_size={test_chunk_size}: " + f"{chunk.content!r}" + ) + # 内容不得丢失:所有分片的并集必须覆盖原文的每一个非空白字符。 + # 不能用「拼接后等于原文」——chunk_overlap=5 的设计就是让相邻块共享 + # 5 个字符,拼接必然重复,那是正确行为而不是丢失。 + covered = "".join(chunk.content for chunk in split_docs) + for char in long_text.replace("\n", "").replace(" ", ""): + assert char in covered, f"字符 {char!r} 在所有分片中都找不到" + assert split_docs[0].content.startswith("这是一个非常长的句子"), ( + f"首块应保留原文开头,实际为 {split_docs[0].content!r}" + ) + assert split_docs[-1].content.endswith("这是第二部分。"), ( + f"末块应保留原文结尾,实际为 {split_docs[-1].content!r}" + ) # 验证元数据 - assert split_docs[0].metadata["source"] == "test.txt" + assert all(chunk.metadata["source"] == "test.txt" for chunk in split_docs) finally: # 恢复原始配置 diff --git a/tests/retrieval/vdb/test_factory.py b/tests/retrieval/vdb/test_factory.py index fe9881d..973e540 100644 --- a/tests/retrieval/vdb/test_factory.py +++ b/tests/retrieval/vdb/test_factory.py @@ -1,3 +1,4 @@ +import json import sys from typing import Any from unittest.mock import MagicMock, patch @@ -157,10 +158,22 @@ def _write_snapshot(root, snapshot_id, provider, model): chunk_count=1, ) (snapshot_dir / "manifest.toml").write_text(manifest.to_toml(), encoding="utf-8") - # 快照目录校验要求这一组文件同时存在;兼容性检测发生在 load_snapshot 之前, - # 所以这里只需占位内容,不参与断言。 - for name in ("chunks.pkl", "parents.pkl", "semantic.index", "embeddings.npy", "stats.json"): - (snapshot_dir / name).write_bytes(b"") + # 快照目录校验除了「文件齐全」还要求 chunks.pkl 与 embeddings.npy 的行数自洽 + # (见 SnapshotRepository._validate_snapshot_row_counts),因此这里写最小但 + # 可解析的内容:1 个分块 + 1 行向量。兼容性检测本身发生在 load_snapshot 之前, + # 这些内容不参与断言。 + import pickle + + import numpy as np + + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "占位分块", "metadata": {"source": "placeholder.md"}}], file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + (snapshot_dir / "parents.pkl").write_bytes(b"") + (snapshot_dir / "semantic.index").write_bytes(b"") + (snapshot_dir / "stats.json").write_text( + json.dumps({"chunk_count": 1}, ensure_ascii=False), encoding="utf-8" + ) (Path(root) / "ACTIVE_SNAPSHOT").write_text(snapshot_id, encoding="utf-8") return snapshot_dir diff --git a/tests/retrieval/vdb/test_faiss_index_text.py b/tests/retrieval/vdb/test_faiss_index_text.py index 13c8cc0..5ff7e1b 100644 --- a/tests/retrieval/vdb/test_faiss_index_text.py +++ b/tests/retrieval/vdb/test_faiss_index_text.py @@ -22,3 +22,89 @@ def test_build_index_text_keeps_original_content_when_source_missing(): index_text = FaissStore._build_index_text(document) assert index_text == "只有正文" + + +# ── 回归:BM25 分数全 0 时的静默空结果,以及空语料的除零 ── + + +def _store_with_corpus(texts): + """用 jieba 与检索侧一致的分词构造一个可用的 FaissStore。""" + import jieba + import numpy as np + import rank_bm25 + + store = FaissStore(file_path=None) + store.documents = [ + {"page_content": text, "metadata": {"source": f"s{index}.md"}} + for index, text in enumerate(texts) + ] + store.embeddings = np.zeros((len(texts), 4), dtype=np.float32) + store._tokenized_docs_cache = [list(jieba.cut(text)) for text in texts] + store.bm25_index = rank_bm25.BM25Okapi(store._tokenized_docs_cache) + return store + + +def test_bm25_idf_is_exactly_zero_when_term_in_half_the_corpus(): + """钉住前提:某个词恰好出现在一半文档里时 idf **恰为** 0,不会被 epsilon 浮动。 + + ``BM25Okapi._calc_idf`` 用 ``log(N - n + 0.5) - log(n + 0.5)``,且只对 + ``idf < 0`` 做 ``eps * average_idf`` 浮动。``n == N/2`` 时 idf 正好是 0, + 不属于「负」因此不被浮动,该词在所有文档上的分数就全是 0。 + """ + store = _store_with_corpus(["苹果 甲", "苹果 乙", "香蕉 丙", "香蕉 丁"]) + + assert store.bm25_index.idf["苹果"] == 0.0 + assert store.bm25_index.get_scores(["苹果"]).tolist() == [0.0, 0.0, 0.0, 0.0] + + +def test_keyword_search_returns_empty_for_zero_idf_term(): + """全 0 分时返回空结果本身说得通(该词无判别力),关键是不得报错。""" + store = _store_with_corpus(["苹果 甲", "苹果 乙", "香蕉 丙", "香蕉 丁"]) + + assert store.keyword_search("苹果") == [] + + +def test_keyword_search_keeps_positive_scores_after_zero_score_entries(): + """0 分文档只能被跳过,不能让循环提前终止丢掉后面的正分结果。 + + 旧实现是 ``if score <= 0: break``,分数降序后一旦遇到 0 分就整段放弃。 + """ + # 「独占词」只出现在一篇文档里 -> idf 为正;「苹果」恰好 N/2 -> idf 为 0 + store = _store_with_corpus(["苹果 独占标记", "苹果 乙", "香蕉 丙", "香蕉 丁"]) + rows = store.bm25_index.get_scores(list(__import__("jieba").cut("独占标记"))) + + assert max(rows) > 0, "前提:该词必须有正分,否则这条测试无法区分两种实现" + assert ( + store.keyword_search("独占标记") + == [document for document in store.documents if document["metadata"]["source"] == "s0.md"] + or store.keyword_search("独占标记")[0]["metadata"]["source"] == "s0.md" + ) + + +def test_build_bm25_index_returns_none_for_empty_corpus(): + """空语料必须返回 None,不能让 rank_bm25 除零。 + + ``BM25._initialize`` 计算 ``avgdl = num_doc / self.corpus_size``,空语料直接 + ``ZeroDivisionError``。``load_snapshot`` 会从 ``lexical.index`` 读回 ``[]`` + (空快照或手工构造的目录),旧实现在此崩溃且不报「快照为空」这个真实原因。 + """ + assert FaissStore._build_bm25_index([]) is None + assert FaissStore._build_bm25_index([["词"]]) is not None + + +def test_load_snapshot_tolerates_empty_lexical_index(tmp_path): + """``lexical.index`` 为空列表时应正常加载(bm25_index 为 None),不得崩溃。""" + import pickle + + import numpy as np + + for name, payload in (("chunks.pkl", []), ("parents.pkl", {}), ("lexical.index", [])): + with (tmp_path / name).open("wb") as file: + pickle.dump(payload, file) + np.save(tmp_path / "embeddings.npy", np.zeros((0, 4), dtype=np.float32)) + + store = FaissStore(file_path=None) + store.load_snapshot(str(tmp_path)) + + assert store.bm25_index is None + assert store.keyword_search("任意查询") == [] diff --git a/tests/retrieval/vdb/test_snapshot_repository.py b/tests/retrieval/vdb/test_snapshot_repository.py index 32cad76..fc94278 100644 --- a/tests/retrieval/vdb/test_snapshot_repository.py +++ b/tests/retrieval/vdb/test_snapshot_repository.py @@ -1,3 +1,7 @@ +from pathlib import Path + +import pytest + from src.retrieval.snapshot_repository import SnapshotRepository from src.runtime.contracts import KnowledgeSnapshotManifest, build_run_config from src.utils.config import get_settings @@ -55,3 +59,121 @@ def test_snapshot_repository_cleans_only_requested_temp_dir(tmp_path): assert not temp_dir.exists() assert (final_dir / "keep.bin").exists() + + +# ── 回归:快照行数自洽(原先只查文件存在性)── + + +def _write_row_counts_snapshot( + root, snapshot_id, chunk_count, embedding_rows, declared_chunk_count=None +): + """按真实结构写一个快照,chunk/embedding 行数可独立指定。""" + import json + import pickle + + import numpy as np + + from src.runtime.contracts import KnowledgeSnapshotManifest + + snapshot_dir = Path(root) / snapshot_id + snapshot_dir.mkdir(parents=True, exist_ok=True) + manifest = KnowledgeSnapshotManifest.create( + snapshot_id=snapshot_id, + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="test-digest", + document_count=chunk_count, + chunk_count=chunk_count, + ) + (snapshot_dir / "manifest.toml").write_text(manifest.to_toml(), encoding="utf-8") + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump( + [ + {"page_content": f"chunk {index}", "metadata": {"source": f"s{index}.md"}} + for index in range(chunk_count) + ], + file, + ) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((embedding_rows, 4), dtype=np.float32)) + (snapshot_dir / "semantic.index").write_bytes(b"") + (snapshot_dir / "stats.json").write_text( + json.dumps( + { + "chunk_count": declared_chunk_count + if declared_chunk_count is not None + else chunk_count + } + ), + encoding="utf-8", + ) + return snapshot_dir + + +def test_validate_snapshot_dir_rejects_chunk_embedding_row_mismatch(tmp_path): + """分块数与向量数不一致时必须拒绝。 + + 修复前实测:``documents=3`` 而 ``embeddings=(2,4)``(``faiss_index.ntotal=2``) + 的 store,``save_snapshot`` 正常落盘、``validate_snapshot_dir`` 通过、加载后 + 也不报错——不一致被完整持久化,之后 ``semantic_search`` 拿 ``indices`` 去索引 + ``self.documents`` 会越界,而报错现场离真正的原因很远。 + """ + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-mismatch-rows", chunk_count=3, embedding_rows=2 + ) + + with pytest.raises(ValueError, match="不自洽"): + repository.validate_snapshot_dir(snapshot_dir) + + +def test_validate_snapshot_dir_rejects_stats_chunk_count_mismatch(tmp_path): + """``stats.json`` 声明的 chunk_count 与实际分块数不符也必须拒绝。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-bad-stats", chunk_count=3, embedding_rows=3, declared_chunk_count=99 + ) + + with pytest.raises(ValueError, match="stats.json"): + repository.validate_snapshot_dir(snapshot_dir) + + +def test_validate_snapshot_dir_accepts_self_consistent_snapshot(tmp_path): + """行数自洽的快照必须通过——收紧不能把正常快照一起拒掉。""" + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-good", chunk_count=3, embedding_rows=3 + ) + + repository.validate_snapshot_dir(snapshot_dir) + + +def test_validate_snapshot_dir_reports_unparseable_chunks_as_value_error(tmp_path): + """截断的文件应报「快照不自洽」的 ``ValueError``,而不是裸 ``EOFError``。 + + 加载入口抛与真实原因无关的异常类型会让用户误诊,测试里就复现过占位空文件 + 导致的 ``EOFError: Ran out of input``。 + """ + from src.retrieval.snapshot_repository import SnapshotRepository + from src.utils.config import get_settings + + repository = SnapshotRepository(build_run_config(get_settings())) + snapshot_dir = _write_row_counts_snapshot( + repository.root, "kb-truncated", chunk_count=3, embedding_rows=3 + ) + (snapshot_dir / "chunks.pkl").write_bytes(b"") + + with pytest.raises(ValueError, match="无法解析"): + repository.validate_snapshot_dir(snapshot_dir) diff --git a/tests/runtime/__init__.py b/tests/runtime/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/runtime/test_contracts.py b/tests/runtime/test_contracts.py new file mode 100644 index 0000000..d010f5f --- /dev/null +++ b/tests/runtime/test_contracts.py @@ -0,0 +1,117 @@ +"""``SessionConfig`` 是运行期可变的独立配置入口,必须自带字段校验。 + +``Settings`` 只在启动时校验一次;UI(``src/ui/config_menu.py``)与库调用方拿到的是 +``SessionConfig``,它们经 ``__setitem__`` 写入的值此前完全不校验。修复前实测 +``chat_config["top_k"] = 10**9`` 能一路走到 ``faiss_index.search()``:FAISS 既不报错 +也不截断,按 10 亿条分配(实测 10**6 时就已分配满槽位并耗时 1.56s)。 +""" + +import pytest + +from src.runtime.contracts import SessionConfig, build_session_config +from src.utils.config import ( + _MAX_RETRIEVAL_MULTIPLIER, + _MAX_RETRIEVAL_TOP_K, + RetrievalMethod, + Settings, +) + + +@pytest.fixture +def session_config() -> SessionConfig: + return build_session_config(Settings(_env_file=None)) + + +@pytest.mark.parametrize( + "field_name,bad_value", + [ + ("top_k", 10**9), + ("top_k", 0), + ("top_k", -1), + ("top_k", 99999999999999999999), + ("top_k", "5"), + ("top_k", True), + ("top_k", 5.5), + ("retrieval_candidate_multiplier", 10**6), + ("retrieval_candidate_multiplier", 0), + ("vector_weight", -5), + ("vector_weight", 1.5), + ("vector_weight", float("nan")), + ("keyword_weight", float("inf")), + ("score_threshold", -999), + ("score_threshold", 2.0), + ("retrieval_method", "totally-invalid"), + ("hybrid_fusion_strategy", "bogus"), + ("rerank_enabled", "yes"), + ("chat_temperature", -1.0), + ("chat_temperature", float("nan")), + ], +) +def test_session_config_rejects_illegal_values(session_config, field_name, bad_value): + """各字段的非法值必须在写入时拒绝,不能拖到检索期。""" + with pytest.raises(ValueError, match=field_name): + session_config[field_name] = bad_value + + +def test_session_config_rejects_unknown_field(session_config): + """拼错字段名应报错,而不是静默新增一个属性。""" + with pytest.raises(KeyError, match="没有字段"): + session_config["不存在的字段"] = 1 + + +@pytest.mark.parametrize( + "field_name,good_value,expected", + [ + ("top_k", 20, 20), + ("top_k", _MAX_RETRIEVAL_TOP_K, _MAX_RETRIEVAL_TOP_K), + ("retrieval_candidate_multiplier", _MAX_RETRIEVAL_MULTIPLIER, _MAX_RETRIEVAL_MULTIPLIER), + ("vector_weight", 0.3, 0.3), + ("score_threshold", 0.0, 0.0), + ("score_threshold", 1.0, 1.0), + ], +) +def test_session_config_accepts_legal_boundary_values( + session_config, field_name, good_value, expected +): + """上界与下界本身必须可用——收紧不能把合法边界一起拒掉。""" + session_config[field_name] = good_value + assert session_config[field_name] == expected + + +def test_session_config_normalizes_retrieval_method_string(session_config): + """UI 传的是枚举值的中文 value,应被归一化成枚举成员。""" + session_config["retrieval_method"] = "全文检索" + assert session_config["retrieval_method"] is RetrievalMethod.FULL_TEXT_SEARCH + + session_config["retrieval_method"] = RetrievalMethod.HYBRID_SEARCH + assert session_config["retrieval_method"] is RetrievalMethod.HYBRID_SEARCH + + +def test_session_config_validator_registry_covers_scalar_fields(): + """注册表必须覆盖所有标量字段。 + + 漏登记会让该字段回落到「原样写入」,也就是本次修复前的无校验状态—— + 这类静默回归不会让任何行为测试变红,只能靠结构断言盯住。 + """ + scalar_fields = { + "retrieval_method", + "vector_weight", + "keyword_weight", + "hybrid_fusion_strategy", + "retrieval_candidate_multiplier", + "rerank_enabled", + "top_k", + "score_threshold", + "chat_temperature", + } + registered = set(SessionConfig._VALIDATORS) + assert scalar_fields <= registered, f"以下标量字段没有登记校验器: {scalar_fields - registered}" + # 反方向:注册表里的字段必须真实存在,否则是拼错名字的死登记 + known_fields = set(build_session_config(Settings(_env_file=None)).to_dict()) + assert registered <= known_fields, f"注册表里有不存在的字段: {registered - known_fields}" + + +def test_session_config_to_dict_still_reflects_writes(session_config): + """写入后 ``to_dict`` 必须能看到新值(否则 UI 显示会与实际不符)。""" + session_config["top_k"] = 42 + assert session_config.to_dict()["top_k"] == 42 diff --git a/tests/services/test_embedding_service.py b/tests/services/test_embedding_service.py index ad222c8..c1cbca8 100644 --- a/tests/services/test_embedding_service.py +++ b/tests/services/test_embedding_service.py @@ -189,3 +189,37 @@ async def aclose(): assert closed assert service._embedding_model is None + + +# ── 回归:向量矩阵的有限性校验 ── + + +@pytest.mark.parametrize( + "bad_row,label", + [ + ([float("nan"), 1.0], "NaN"), + ([float("inf"), 1.0], "正无穷"), + ([float("-inf"), 1.0], "负无穷"), + ([1e300, 1.0], "超出 float32 范围(cast 后静默溢出为 inf)"), + ], +) +def test_as_float32_matrix_rejects_non_finite_values(bad_row, label): + """含 NaN/无穷大的向量必须在进入索引前拒绝。 + + FAISS 会把 NaN 向量照单收下(``ntotal`` 正常增加),直到查询时才以 + 3.4e38 的哨兵距离暴露出来——那时「哪个文档坏了」已经无法定位。 + ``np.array(..., dtype=np.float32)`` 对超出 float32 范围的值只发 + RuntimeWarning 并静默溢出成 inf,所以在 cast **之后**检查。 + """ + with pytest.raises(ValueError, match="非有限值"): + EmbeddingService._as_float32_matrix([[1.0, 2.0], bad_row], expected_rows=2) + + +def test_as_float32_matrix_accepts_finite_values(): + """收紧不能误伤:正常向量(含 0 与负值)必须可用。""" + matrix = EmbeddingService._as_float32_matrix( + [[0.0, -1.5, 2.25], [-0.0, 1e-30, 3.4e38]], expected_rows=2 + ) + + assert np.isfinite(matrix).all() + assert matrix.shape == (2, 3) diff --git a/tests/test_config.py b/tests/test_config.py index 73842fc..af344dd 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -80,8 +80,8 @@ def test_settings_model_validation(): @pytest.mark.parametrize( ("field_name", "bad_value", "expected_message"), [ - ("log_retention_days", -5, "log_retention_days 必须是大于等于 1 的整数"), - ("log_retention_days", 0, "log_retention_days 必须是大于等于 1 的整数"), + ("log_retention_days", -5, "log_retention_days 必须是 1 到 3650 之间的整数"), + ("log_retention_days", 0, "log_retention_days 必须是 1 到 3650 之间的整数"), ("kb_chunk_size", 0, "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size"), ("kb_chunk_size", -100, "kb_chunk_size/kb_child_chunk_size/kb_embedding_batch_size"), ("kb_chunk_overlap", -1, "kb_chunk_overlap/kb_child_chunk_overlap 必须是非负整数"), @@ -584,3 +584,101 @@ def test_get_settings_reports_non_credential_validation_errors(isolated_config): with pytest.raises(ValueError, match="chat_top_k"): get_settings() + + +# ── 回归:数值解析不得宽松放行「看起来成功」的形态 ── + + +@pytest.mark.parametrize( + "raw", + [ + "1_0", # Python 下划线分组:int("1_0") == 10、float("1_0") == 10.0 + "+7", # 正号:float("+7") == 7.0 + "0x10", # 十六进制 + "inf", + "-inf", + "nan", + "1 500", + "1,500", + "", + "abc", + ], +) +def test_numeric_settings_reject_non_decimal_string_forms(raw): + """字符串数值只接受十进制字面量,不放行下划线分组/正号/十六进制/非有限值。 + + ``int()`` 与 ``float()`` 都接受 ``"1_0"`` 这类 Python 字面量形态 + (``int("1_0") == 10``、``float("1_0") == 10.0``),配置里写错时会被静默 + 读成另一个数——安静的成功正是本项目反复踩到的模式,因此在解析前先做形状校验。 + """ + with pytest.raises(ValidationError, match="无法把 kb_chunk_size"): + Settings(kb_chunk_size=raw) + + +@pytest.mark.parametrize( + "raw,expected", + [("1500", 1500), (" 1500 ", 1500), ("2000", 2000), ("1500\n", 1500)], +) +def test_numeric_settings_accept_decimal_integer_strings(raw, expected): + """收紧不能误伤:正常十进制字符串(含首尾空白)仍必须可用。 + + 取值都避开既有的交叉约束 ``kb_chunk_overlap < kb_chunk_size``:overlap 默认 + 150,所以 ``kb_chunk_size="1"`` 会被它拒绝,与本次形状校验无关。 + """ + assert Settings(kb_chunk_size=raw).kb_chunk_size == expected + + +def test_numeric_settings_accept_scientific_notation_for_float_fields(): + """float 字段仍接受科学计数法——这是合法的十进制字面量。""" + assert Settings(chat_score_threshold="1e-1").chat_score_threshold == pytest.approx(0.1) + assert Settings(chat_temperature="1e0").chat_temperature == pytest.approx(1.0) + + +@pytest.mark.parametrize( + "field_name,bad_value", + [ + ("log_retention_days", 10**400), + ("kb_chunk_size", 10**400), + ("kb_child_chunk_size", 10**400), + ("kb_embedding_batch_size", 10**400), + ("log_retention_days", "1" + "0" * 400), + ("kb_chunk_size", "1" + "0" * 400), + ], +) +def test_integer_settings_reject_overflow_magnitudes(field_name, bad_value): + """溢出量级的整数必须在加载期拒绝,而不是拖到运算期。 + + 实测修复前 ``log_retention_days=10**400`` 与 ``kb_chunk_size=10**400`` + 都被接受(这两个字段当时只有下界),直到日期减法/内存申请才炸。 + """ + with pytest.raises(ValidationError, match=field_name): + Settings(**{field_name: bad_value}) + + +def test_score_threshold_overflow_is_a_validation_error_not_overflow_error(): + """``float(10**400)`` 抛的是裸 ``OverflowError``;它**不是** ``ValidationError``, + 会穿透 ``get_settings`` 的 ``except`` 变成未脱敏 traceback。因此范围判断必须在 + 转 float 之前完成。 + + 这里用 ``Settings`` 直接断言异常类型:``get_settings`` 不接受 kwargs(它从 + TOML/env 取值),无法注入这种量级的值,而两条路径共用同一个 validator。 + """ + for bad in (10**400, "1" + "0" * 400): + with pytest.raises(ValidationError, match="chat_score_threshold") as excinfo: + Settings(chat_score_threshold=bad) + assert not isinstance(excinfo.value.__cause__, OverflowError) + + +def test_get_settings_wraps_settings_error_from_unparseable_env_list(monkeypatch, isolated_config): + """列表类 env 字段写错时 pydantic-settings 抛 ``SettingsError``,它继承 + ``ValueError`` 但**不是** ``ValidationError``,原先的 handler 接不住,用户看到 + 的是解释器默认 handler 打出的多屏 traceback。现在必须变成一行脱敏消息。 + """ + monkeypatch.setenv("KB_SPLITTER_SEPARATORS", "###") + get_settings.cache_clear() + + with pytest.raises(ValueError, match="配置解析失败") as excinfo: + get_settings() + + get_settings.cache_clear() + assert "kb_splitter_separators" in str(excinfo.value) From a4f483a2c783b7edf4f691581aa006814d474cda Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 21:58:35 +0800 Subject: [PATCH 29/31] =?UTF-8?q?fix:=20=E7=BB=99=20pickle=20=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=8A=A0=E4=BF=A1=E4=BB=BB=E8=BE=B9=E7=95=8C=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20legacy=20=E6=B8=85=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=8C=85=E7=AC=A6=E5=8F=B7=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第三轮审查剩余项中可由代码独立处理的部分。重点是 AGENTS.md 明确要求「只从本项目 生成的本地快照或明确受信的 legacy 文件加载」,但此前代码不做任何来源校验。 pickle 信任边界(新增) - 快照内的 chunks.pkl / parents.pkl / lexical.index 都是 pickle,反序列化即执行 代码,而路径本身不能证明来源。此前 `load_snapshot(dir)` 接受任意目录、 `load(path)` 接受任意路径、`FaissStore(file_path=...)` 还会隐式加载。 - `src/utils/security.py` 新增 `resolve_within()` / `ensure_trusted_source()`: 两侧都先 `resolve()` 再比较,符号链接逃逸(`root/link -> /etc`)会被拦下; `trusted_roots` 为空时一律拒绝,不退化成「任意路径都可加载」。 - `load_snapshot()` 现在要求调用方给出 `snapshot_root`,并校验三件事:目录解析后 在信任根内、目录本身不是符号链接、`ACTIVE_SNAPSHOT` 标记指向的就是该目录。 第 3 条是关键——仅「在 root 之下」不足以证明目录可用,未激活或半成品目录同样在 root 之下。`load()` 要求 `trusted_paths`;`__init__(file_path=...)` 不再隐式加载。 - `SnapshotRepository.validate_snapshot_dir()` 同步加归属校验:必须在信任根内、 非符号链接、且是根的直接子目录。 - `factory.py` 的两个加载点分别传 `run_config.snapshot_root` 与 `legacy_path.parent`;`base.py` 抽象签名同步。 manifest schema 准入 - `load_manifest()` 之前只 `str(data["schema_version"])` 解析、不比对,未知版本被 静默接受并照着当前代码解释——v1→v2 调整过分块与 embedding 的落盘结构,这样会 得到错误结果而不是报错。现在版本不符即拒绝,消息给出快照版本与当前版本。 - 版本判断放在 `load_manifest` 而不是 `from_mapping`:后者保持纯解析,避免「解析」 与「准入」两件事在同一处各判一次。 legacy 导入的临时目录清理 - `factory.py` 的 legacy 分支此前没有 `KnowledgeBuildService.build` 那样的 `finalized`/`finally` 结构,`create_temp_snapshot_dir` 之后任何失败都会在快照根 留下 `.tmp-legacy-*` 并随时间累积。 - 照搬同一结构补齐;并把 `validate_snapshot_dir(temp_dir)` 提到 `finalize_snapshot` **之前**——finalize 会更新 ACTIVE_SNAPSHOT,校验放到它后面 就只剩「坏快照已经被激活」这一种收场方式。清理失败只记日志,不掩盖真正的失败 原因(与 knowledge_build_service 一致)。 发布包的符号链接 - ZIP 分支(Windows 是唯一 zip 目标)用 `rglob` + `ZipFile.write`,遇到悬空符号 链接抛裸 `FileNotFoundError`,消息里看不出「这是发布包里的符号链接」;而 tar.gz 分支对同一目录完全正常,两分支行为不一致。 - 新增 `find_dangling_symlinks()`,归档前显式检查并抛 `RuntimeError`,列出全部 悬空路径并说明处置方式。tar.gz 分支保持既有行为(验证过它保留符号链接成员)。 警告可见性 - `pytest.ini` 原先全局 `ignore::UserWarning` / `ignore::DeprecationWarning`,把四个 快速迭代的 SDK 升级警告和本项目自身警告一起静音了(实测同一探针用例在全局忽略下 静默通过、在 `-W error::UserWarning` 下失败)。 - 改为第三方 module 前缀限定;新增断言本仓库配置加载路径不产生这两类警告, 防止「收窄过滤」在未来某次改动里悄悄失效。收窄后全量测试无新增失败。 测试 - 新增 21 条回归断言(1226 → 1247),全部红绿验证。 - 补齐此前完全空缺的覆盖:legacy 导入路径(含失败清理)、`archive_bundle` (此前零测试)、信任边界的 6 类拒绝用例与合法路径成功用例。 - 按新契约更新既有调用点:`MockFaissStore` 签名、`load_snapshot` 直调改为传 `snapshot_root` 并构造 ACTIVE_SNAPSHOT 标记。 质量门(全部实跑):pytest 1247 passed、ruff format/check、mypy 49 files、 bandit -ll、compileall、uv lock --check、git diff --check。 端到端烟测:`main.py --smoke-test` 正常、`CHAT_TOP_K=0` 仍走脱敏消息、 合法活动快照可加载且根外/未激活目录被拒。 --- pytest.ini | 24 ++- scripts/build_binary_release.py | 23 +++ src/retrieval/snapshot_repository.py | 33 +++- src/retrieval/vdb/base.py | 12 +- src/retrieval/vdb/factory.py | 72 +++++--- src/retrieval/vdb/faiss_store.py | 95 ++++++++-- src/utils/security.py | 61 ++++++- tests/retrieval/vdb/test_factory.py | 83 ++++++++- tests/retrieval/vdb/test_faiss_index_text.py | 10 +- .../vdb/test_faiss_sidecar_roundtrip.py | 168 +++++++++++++++++- .../retrieval/vdb/test_snapshot_repository.py | 132 ++++++++++++++ tests/test_config.py | 34 ++++ tests/test_release_scripts.py | 82 +++++++++ 13 files changed, 775 insertions(+), 54 deletions(-) diff --git a/pytest.ini b/pytest.ini index c501f07..30449bb 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,7 +4,25 @@ testpaths = tests # langsmith is a transitive dependency of langchain and its pytest plugin # imports the full tracing SDK during collection; PyRAG-Kit does not use it. addopts = -p no:langsmith -# 忽略第三方库的 DeprecationWarning 和 UserWarning +# 只忽略第三方依赖的噪音;本仓库代码产生的警告必须可见。 +# +# 原先这里是全局 ``ignore::UserWarning`` / ``ignore::DeprecationWarning``, +# 等于把四个快速迭代的 SDK(openai / google-genai / anthropic / openpyxl)的 +# 升级警告和本项目自身的警告一起静音了——实测同一个探针用例在全局忽略下静默 +# 通过、在 ``-W error::UserWarning`` 下失败,说明警告确实发生却被吞掉。 filterwarnings = - ignore::DeprecationWarning - ignore::UserWarning + default::DeprecationWarning + default::UserWarning + # 第三方依赖的已知噪音:按 module 前缀限定,不用全局忽略。 + ignore::DeprecationWarning:pydantic.* + ignore::DeprecationWarning:pydantic_settings.* + ignore::DeprecationWarning:langchain.* + ignore::DeprecationWarning:langchain_core.* + ignore::DeprecationWarning:langchain_text_splitters.* + ignore::DeprecationWarning:google.* + ignore::DeprecationWarning:openai.* + ignore::DeprecationWarning:anthropic.* + ignore::DeprecationWarning:jieba.* + ignore::DeprecationWarning:rank_bm25.* + ignore::UserWarning:jieba.* + ignore::UserWarning:rank_bm25.* diff --git a/scripts/build_binary_release.py b/scripts/build_binary_release.py index 9424ae3..74f0f19 100755 --- a/scripts/build_binary_release.py +++ b/scripts/build_binary_release.py @@ -195,8 +195,31 @@ def prepare_runtime_layout(bundle_root: Path) -> None: ) +def find_dangling_symlinks(bundle_root: Path) -> list[Path]: + """列出 bundle 内目标不存在的符号链接。 + + ``ZipFile.write`` 会解引用符号链接去取元数据,目标缺失时抛的是裸 + ``FileNotFoundError``(不带任何「这是发布包符号链接」的线索),而 + ``tarfile.add`` 会把链接本身写进归档、不报错。两个分支行为不一致, + 且在 Windows 这个唯一的 zip 目标上表现为一个难以归因的构建失败。 + 归档前先显式找出它们,让失败信息说清是什么、有几条。 + """ + return sorted( + entry for entry in bundle_root.rglob("*") if entry.is_symlink() and not entry.exists() + ) + + def archive_bundle(bundle_root: Path, target: str) -> Path: if target == "windows-x64": + dangling = find_dangling_symlinks(bundle_root) + if dangling: + listed = "\n ".join(str(path.relative_to(bundle_root)) for path in dangling) + raise RuntimeError( + f"发布包内含 {len(dangling)} 个目标不存在的符号链接,无法打包为 ZIP:" + f"\n {listed}\n" + " ZIP 格式无法保留符号链接,``ZipFile.write`` 又会解引用它们," + "请检查构建产物;若这些链接是有意为之,请改用 tar.gz 目标。" + ) archive_path = bundle_root.parent / f"{bundle_root.name}.zip" with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: for file_path in sorted(bundle_root.rglob("*")): diff --git a/src/retrieval/snapshot_repository.py b/src/retrieval/snapshot_repository.py index f543a5d..847efb5 100644 --- a/src/retrieval/snapshot_repository.py +++ b/src/retrieval/snapshot_repository.py @@ -7,7 +7,8 @@ import uuid from pathlib import Path -from src.runtime.contracts import KnowledgeSnapshotManifest, RunConfig +from src.runtime.contracts import SCHEMA_VERSION, KnowledgeSnapshotManifest, RunConfig +from src.utils.security import resolve_within class SnapshotRepository: @@ -65,9 +66,24 @@ def write_manifest(self, snapshot_dir: Path, manifest: KnowledgeSnapshotManifest (snapshot_dir / "manifest.toml").write_text(manifest.to_toml(), encoding="utf-8") def load_manifest(self, snapshot_dir: Path) -> KnowledgeSnapshotManifest: + """读取 manifest,并校验 ``schema_version`` 与当前实现一致。 + + 版本放在这里判而不是 ``from_mapping`` 里:``from_mapping`` 是纯解析, + 塞进版本策略会让「解析」和「准入」两件事在同一处各判一次。未知版本必须 + 拒绝——旧/新 schema 的字段语义可能已经变了(本项目 v1 到 v2 就调整过 + 分块与 embedding 的落盘结构),照着当前代码去解释未知版本会得到错误结果 + 而不是报错。 + """ with (snapshot_dir / "manifest.toml").open("rb") as file: data = tomllib.load(file) - return KnowledgeSnapshotManifest.from_mapping(data) + manifest = KnowledgeSnapshotManifest.from_mapping(data) + if manifest.schema_version != SCHEMA_VERSION: + raise ValueError( + "知识快照的 schema 版本与当前程序不兼容。" + f" 快照={manifest.schema_version},当前程序={SCHEMA_VERSION}。" + " 请重建知识快照;如需保留旧快照,请先用生成它的版本导出数据。" + ) + return manifest def validate_snapshot_dir(self, snapshot_dir: Path) -> None: """校验快照文件齐全**且三个数据源的行数自洽**。 @@ -79,6 +95,19 @@ def validate_snapshot_dir(self, snapshot_dir: Path) -> None: ``self.documents`` 会越界,而报错现场离真正的原因(写入时就不一致) 很远。这里在加载/切换快照的入口就把三者对齐。 """ + # 先确认目录归属,再谈内容:快照目录里的 chunks.pkl/parents.pkl/ + # lexical.index 都是 pickle,反序列化即执行代码,而「路径长在 root 附近」 + # 不构成来源证明。resolve_within 会把符号链接逃逸(root/link -> /etc) + # 一并拦下。 + resolved = resolve_within(snapshot_dir, self.root, label="知识快照目录") + if snapshot_dir.is_symlink(): + raise ValueError(f"知识快照目录不能是符号链接: {snapshot_dir}") + if resolved.parent != Path(self.root).resolve(): + raise ValueError( + f"知识快照目录必须直接位于快照根目录之下: {resolved}(根={self.root})。" + ) + snapshot_dir = resolved + required_files = [ snapshot_dir / "manifest.toml", snapshot_dir / "chunks.pkl", diff --git a/src/retrieval/vdb/base.py b/src/retrieval/vdb/base.py index d84ae50..b9b2044 100644 --- a/src/retrieval/vdb/base.py +++ b/src/retrieval/vdb/base.py @@ -37,8 +37,8 @@ def save(self, path: str): """保存向量存储。""" @abstractmethod - def load(self, path: str): - """加载向量存储。""" + def load(self, path: str, *, trusted_paths: Any = None): + """加载向量存储。``trusted_paths`` 声明可信来源根(实现可据此拒绝任意路径)。""" @abstractmethod def get_embedding_model(self) -> Any: @@ -60,8 +60,12 @@ def save_snapshot(self, snapshot_dir: str): """将当前状态保存为快照。默认未实现。""" raise NotImplementedError("当前向量存储未实现 save_snapshot。") - def load_snapshot(self, snapshot_dir: str): - """从快照目录加载状态。默认未实现。""" + def load_snapshot(self, snapshot_dir: str, *, snapshot_root: Any = None): + """从快照目录加载状态。默认未实现。 + + ``snapshot_root`` 是调用方声明的信任根:快照目录内的文件可能参与反序列化, + 路径本身不足以证明来源,实现应据此校验归属。 + """ raise NotImplementedError("当前向量存储未实现 load_snapshot。") def register_parent_documents(self, parent_documents: dict[str, dict[str, Any]]): diff --git a/src/retrieval/vdb/factory.py b/src/retrieval/vdb/factory.py index 4ead455..c0824d3 100644 --- a/src/retrieval/vdb/factory.py +++ b/src/retrieval/vdb/factory.py @@ -2,20 +2,29 @@ # 原始来源: https://github.com/langgenius/dify # 遵循修改后的 Apache License 2.0 许可证。详情请参阅项目根目录下的 DIFY_LICENSE 文件。 +from collections.abc import Iterable from pathlib import Path from ...runtime.contracts import KnowledgeSnapshotManifest, build_run_config from ...utils.config import get_settings +from ...utils.log_manager import get_module_logger from ..snapshot_repository import SnapshotRepository from .base import VectorStoreBase from .faiss_store import FaissStore +logger = get_module_logger(__name__) + class VectorStoreFactory: @staticmethod - def get_vector_store(store_type: str, file_path: str | None = None) -> VectorStoreBase: + def get_vector_store( + store_type: str, + file_path: str | None = None, + *, + trusted_paths: Iterable[str | Path] | None = None, + ) -> VectorStoreBase: if store_type.lower() == "faiss": - return FaissStore(file_path=file_path) + return FaissStore(file_path=file_path, trusted_paths=trusted_paths) raise ValueError(f"不支持的向量存储类型: {store_type}") @staticmethod @@ -51,31 +60,50 @@ def _load_existing_state(store: VectorStoreBase, run_config) -> None: f"当前={run_config.default_embedding_provider}/{embedding_detail.model_name}。" "请重建知识快照或切换回原 embedding 配置。" ) - store.load_snapshot(str(active_snapshot_dir)) + store.load_snapshot(str(active_snapshot_dir), snapshot_root=run_config.snapshot_root) return legacy_path = Path(run_config.legacy_pkl_path) if not legacy_path.exists(): return - store.load(str(legacy_path)) + # 只信任配置里声明的 legacy 文件所在目录:这条路径要 pickle.load, + # 而路径本身不能证明来源。 + store.load(str(legacy_path), trusted_paths=[legacy_path.parent]) snapshot_id = snapshot_repository.generate_snapshot_id(prefix="legacy") temp_dir = snapshot_repository.create_temp_snapshot_dir(snapshot_id) - store.save_snapshot(str(temp_dir)) - manifest = KnowledgeSnapshotManifest.create( - snapshot_id=snapshot_id, - store_type=run_config.default_vector_store, - embedding_provider=run_config.default_embedding_provider, - embedding_model=run_config.embedding_configurations[ - run_config.default_embedding_provider - ].model_name, - chunk_mode="legacy-import", - source_digest="legacy-import", - document_count=len( - {doc.get("metadata", {}).get("source") for doc in getattr(store, "documents", [])} - ), - chunk_count=len(getattr(store, "documents", [])), - ) - snapshot_repository.write_manifest(temp_dir, manifest) - final_dir = snapshot_repository.finalize_snapshot(temp_dir, snapshot_id) - snapshot_repository.validate_snapshot_dir(final_dir) + finalized = False + try: + store.save_snapshot(str(temp_dir)) + manifest = KnowledgeSnapshotManifest.create( + snapshot_id=snapshot_id, + store_type=run_config.default_vector_store, + embedding_provider=run_config.default_embedding_provider, + embedding_model=run_config.embedding_configurations[ + run_config.default_embedding_provider + ].model_name, + chunk_mode="legacy-import", + source_digest="legacy-import", + document_count=len( + { + doc.get("metadata", {}).get("source") + for doc in getattr(store, "documents", []) + } + ), + chunk_count=len(getattr(store, "documents", [])), + ) + snapshot_repository.write_manifest(temp_dir, manifest) + # 先在临时目录里校验再 finalize:finalize 会更新 ACTIVE_SNAPSHOT, + # 校验放到它后面就只剩「坏快照已经被激活」这一种收场方式。 + snapshot_repository.validate_snapshot_dir(temp_dir) + final_dir = snapshot_repository.finalize_snapshot(temp_dir, snapshot_id) + snapshot_repository.validate_snapshot_dir(final_dir) + finalized = True + finally: + if not finalized: + # 与 KnowledgeBuildService.build 一致:清理失败只记日志, + # 不能掩盖真正的失败原因。 + try: + snapshot_repository.cleanup_temp_snapshot_dir(snapshot_id) + except Exception: + logger.exception("清理临时知识快照失败: %s", temp_dir) diff --git a/src/retrieval/vdb/faiss_store.py b/src/retrieval/vdb/faiss_store.py index 03b888c..44ed7e0 100644 --- a/src/retrieval/vdb/faiss_store.py +++ b/src/retrieval/vdb/faiss_store.py @@ -8,6 +8,7 @@ # Pickle is retained only for trusted local legacy snapshots. import pickle # nosec B403 import time +from collections.abc import Iterable from copy import deepcopy from pathlib import Path from typing import Any @@ -24,6 +25,7 @@ ) from exc from ...utils.log_manager import get_module_logger +from ...utils.security import ensure_trusted_source, resolve_within from .base import VectorStoreBase logger = get_module_logger(__name__) @@ -31,7 +33,19 @@ class FaissStore(VectorStoreBase): - def __init__(self, file_path: str | None = None): + def __init__( + self, + file_path: str | None = None, + *, + trusted_paths: Iterable[str | os.PathLike[str]] | None = None, + ): + """构造空 store,或从一个**显式受信**的 legacy pickle 载入。 + + ``file_path`` 只在同时给出 ``trusted_paths`` 时才加载。pickle 反序列化 + 等同执行代码,路径本身不能证明来源(``AGENTS.md`` 要求只加载本项目生成或 + 明确受信的文件),所以没有受信声明时这里不加载、也不静默改用空索引—— + 直接抛错,让调用方决定是补上信任声明还是走快照加载。 + """ self.file_path = file_path self.documents: list[dict[str, Any]] = [] self.embeddings: np.ndarray | None = None @@ -40,8 +54,11 @@ def __init__(self, file_path: str | None = None): self.bm25_index: BM25Okapi | None = None self.faiss_index: faiss.Index | None = None - if self.file_path and os.path.exists(self.file_path): - self.load(self.file_path) + if not file_path: + return + if not os.path.exists(file_path): + return + self.load(file_path, trusted_paths=trusted_paths) @staticmethod def _normalize_parent_document(parent_document: Any) -> dict[str, Any]: @@ -248,13 +265,23 @@ def save(self, path: str): file, ) - def load(self, path: str): + def load( + self, + path: str | os.PathLike[str], + *, + trusted_paths: Iterable[str | os.PathLike[str]] | None = None, + ): + """从明确受信的 legacy pickle 载入。 + + ``trusted_paths`` 必须是调用方声明的信任根(生产路径下即 + ``RunConfig.legacy_pkl_path`` 的所在目录)。为 ``None`` 或空时拒绝加载: + pickle 会执行任意代码,而「路径看起来像本项目的文件」不构成来源证明。 + """ if not os.path.exists(path): raise FileNotFoundError(f"向量存储文件未找到: {path}") - # Legacy compatibility: this path is only for local files generated by - # PyRAG-Kit. Never point it at an untrusted pickle file. + ensure_trusted_source(path, trusted_paths, label="legacy 向量存储文件") with open(path, "rb") as file: - data = pickle.load(file) # nosec B301 + data = pickle.load(file) # nosec B301 - 上方已校验来源 self.documents = data.get("documents", []) self.embeddings = data.get("embeddings") self.parent_documents = data.get("parent_documents", {}) @@ -289,11 +316,45 @@ def save_snapshot(self, snapshot_dir: str): encoding="utf-8", ) - def load_snapshot(self, snapshot_dir: str): - snapshot_path = Path(snapshot_dir) - # SnapshotRepository accepts only application-managed snapshot dirs; - # keep the pickle extension for backward compatibility with existing - # Dify-derived snapshots and do not load arbitrary external files. + def load_snapshot( + self, + snapshot_dir: str | os.PathLike[str], + *, + snapshot_root: str | os.PathLike[str] | None = None, + ): + """从**本应用管理的活动快照**载入。 + + 快照目录里的三个文件都是 pickle,反序列化即执行代码。这里不信任传进来 + 的路径本身,而是要求调用方给出 ``snapshot_root``(生产路径下即 + ``RunConfig.snapshot_root``),然后校验: + + 1. 目录解析后位于 ``snapshot_root`` 之内(符号链接逃逸会被拦下); + 2. ``ACTIVE_SNAPSHOT`` 标记存在,且它指向的目录就是 ``snapshot_dir``。 + + 第 2 条是关键:仅「在 root 之下」还不足以证明目录可用——未激活或半成品 + 目录同样在 root 之下。保留 pickle 扩展名是为了兼容 Dify 衍生的既有快照。 + """ + if not snapshot_root: + raise ValueError( + "缺少 snapshot_root,已拒绝加载快照。" + " 快照目录内的文件是 pickle,必须由调用方声明信任根(" + "RunConfig.snapshot_root 或 SnapshotRepository.root)。" + ) + snapshot_path = resolve_within( + Path(snapshot_dir), Path(snapshot_root), label="知识快照目录" + ) + if not snapshot_path.is_dir(): + raise FileNotFoundError(f"知识快照目录不存在: {snapshot_path}") + marker = snapshot_path.parent / "ACTIVE_SNAPSHOT" + if not marker.exists(): + raise ValueError(f"知识快照目录未被激活,缺少标记文件: {marker}") + active_id = marker.read_text(encoding="utf-8").strip() + active_dir = (marker.parent / active_id).resolve() + if active_dir != snapshot_path: + raise ValueError( + "知识快照目录不是当前活动快照。" + f" 传入={snapshot_path},ACTIVE_SNAPSHOT 指向={active_dir}。" + ) with (snapshot_path / "chunks.pkl").open("rb") as file: self.documents = pickle.load(file) # nosec B301 with (snapshot_path / "parents.pkl").open("rb") as file: @@ -313,9 +374,15 @@ def load_snapshot(self, snapshot_dir: str): self._rebuild_indices() self._normalize_loaded_documents() - def import_legacy_snapshot(self, legacy_path: str): + def import_legacy_snapshot( + self, + legacy_path: str, + *, + trusted_paths: Iterable[str | os.PathLike[str]] | None = None, + ): + """把旧版 pkl 读入内存(不落快照)。与 ``load`` 共用同一来源校验。""" start_time = time.perf_counter() - self.load(legacy_path) + self.load(legacy_path, trusted_paths=trusted_paths) logger.info("旧版 pkl 已导入内存,耗时: %.4fs", time.perf_counter() - start_time) def get_embedding_model(self) -> Any: diff --git a/src/utils/security.py b/src/utils/security.py index 721f6e4..c7a3eb7 100644 --- a/src/utils/security.py +++ b/src/utils/security.py @@ -1,9 +1,11 @@ """配置、请求和日志边界的安全校验工具。""" import copy +import os import re -from collections.abc import Collection, Mapping +from collections.abc import Collection, Iterable, Mapping from itertools import pairwise +from pathlib import Path from typing import Any from urllib.parse import parse_qsl, urlsplit @@ -715,3 +717,60 @@ def validate_secret_free_resource_args( + ", ".join(sorted(set(found))) ) return sanitized + + +def resolve_within( + candidate: str | os.PathLike[str], + root: str | os.PathLike[str], + *, + label: str, +) -> Path: + """把一个路径解析到 ``root`` 之内,越界即拒绝。 + + 用于 pickle 一类「解析即执行」的加载入口:``AGENTS.md`` 要求知识快照只从 + 本项目生成的本地快照或明确受信的 legacy 文件加载,但路径本身并不能证明 + 来源,所以调用方必须给出信任根,由这里判断归属。 + + 两个路径都先 ``resolve()`` 再比较,因此符号链接指向根外时会被拦下 + (``root/link -> /etc`` 这类逃逸)。``resolve()`` 用默认的 + ``strict=False``:目标可以尚不存在,此处只判断归属,存在性由调用方另行校验。 + """ + resolved_root = Path(root).resolve() + resolved_candidate = Path(candidate).resolve() + if resolved_candidate != resolved_root and not resolved_candidate.is_relative_to(resolved_root): + raise ValueError( + f"{label} 必须位于受信根目录内。" + f" 路径={resolved_candidate},受信根={resolved_root}。" + " 请只加载本项目生成的快照;如需导入外部文件,请显式把它放入受信目录。" + ) + return resolved_candidate + + +def ensure_trusted_source( + candidate: str | os.PathLike[str], + trusted_roots: Iterable[str | os.PathLike[str]] | None, + *, + label: str, +) -> Path: + """要求 ``candidate`` 命中给定的受信根之一,否则拒绝加载。 + + ``trusted_roots`` 为 ``None`` 或空集合时一律拒绝——调用方没有声明任何 + 信任来源时,不能退化成「任意路径都可加载」,否则这个边界等于不存在。 + """ + roots = [Path(root) for root in trusted_roots] if trusted_roots else [] + if not roots: + raise ValueError( + f"缺少 {label} 的受信来源声明,已拒绝加载。" + " 请在调用处显式传入 trusted_paths/trusted_roots(通常来自 RunConfig 的" + " snapshot_root 或 legacy_pkl_path)。" + ) + errors: list[str] = [] + for root in roots: + try: + return resolve_within(candidate, root, label=label) + except ValueError as exc: + errors.append(str(exc)) + raise ValueError( + f"{label} 不在任何受信路径内。路径={Path(candidate)}。" + f" 受信路径={[str(root) for root in roots]}。" + ) diff --git a/tests/retrieval/vdb/test_factory.py b/tests/retrieval/vdb/test_factory.py index 973e540..40ead6b 100644 --- a/tests/retrieval/vdb/test_factory.py +++ b/tests/retrieval/vdb/test_factory.py @@ -40,11 +40,14 @@ async def asearch( def save(self, path: str): """模拟保存操作""" - def load(self, path: str): - """模拟加载操作""" + def load(self, path: str, *, trusted_paths=None): + """模拟加载操作(签名与 FaissStore 一致,含来源校验参数)。""" + self.file_path = path - def load_snapshot(self, snapshot_dir: str): + def load_snapshot(self, snapshot_dir: str, *, snapshot_root=None): """模拟从快照目录加载,记录路径供断言。""" + if not snapshot_root: + raise ValueError("缺少 snapshot_root,已拒绝加载快照。") self.file_path = snapshot_dir def get_embedding_model(self) -> Any: @@ -98,7 +101,9 @@ def patch_settings(monkeypatch, tmp_path): ) # 直接模拟 VectorStoreFactory.get_vector_store 方法 - def mock_get_vector_store(store_type: str, file_path: str | None = None) -> VectorStoreBase: + def mock_get_vector_store( + store_type: str, file_path: str | None = None, *, trusted_paths=None + ) -> VectorStoreBase: if store_type.lower() == "faiss": return MockFaissStore(file_path=file_path) else: @@ -213,3 +218,73 @@ def test_default_vector_store_accepts_matching_embedding(tmp_path): store = VectorStoreFactory.get_default_vector_store() assert isinstance(store, MockFaissStore) + + +# ── 回归:legacy 导入失败必须清理 .tmp-*,且不污染已有快照 ── + + +class _PartialWriterStore: + """``save_snapshot`` 只落一个残缺文件,模拟构建中途失败。""" + + def __init__(self): + self.documents = [{"page_content": "旧数据", "metadata": {"source": "old.md"}}] + + def load(self, path: str, *, trusted_paths=None): + return + + def save_snapshot(self, snapshot_dir: str) -> None: + from pathlib import Path + + (Path(snapshot_dir) / "chunks.pkl").write_bytes(b"partial") + + +def test_legacy_import_cleans_temp_dir_on_failure(tmp_path): + """legacy 导入在 ``create_temp_snapshot_dir`` 之后失败时必须清掉 ``.tmp-*``。 + + ``factory.py`` 的 legacy 分支原先没有 ``knowledge_build_service`` 那样的 + ``finalized``/``finally`` 结构,任何中途失败都会在快照根目录留下 + ``.tmp-legacy-*``,并随时间累积。 + """ + import pickle + from pathlib import Path + + from src.retrieval.snapshot_repository import SnapshotRepository + from src.retrieval.vdb.factory import VectorStoreFactory + from src.runtime.contracts import build_run_config + from src.utils.config import get_settings + + settings = get_settings() + snapshot_root = Path(settings.snapshot_root) + snapshot_root.mkdir(parents=True, exist_ok=True) + + # 已有正式快照 + 活动标记,断言它们不被 legacy 导入的失败牵连 + previous = snapshot_root / "previous" + previous.mkdir(exist_ok=True) + (previous / "keep.txt").write_text("保留", encoding="utf-8") + (snapshot_root / "ACTIVE_SNAPSHOT").write_text("previous", encoding="utf-8") + + legacy_path = Path(settings.pkl_path) + legacy_path.parent.mkdir(parents=True, exist_ok=True) + with legacy_path.open("wb") as file: + pickle.dump({"documents": [], "embeddings": None}, file) + + run_config = build_run_config(settings) + repository = SnapshotRepository(run_config) + monkeypatch_generated = "legacy-fixed-id" + + with ( + patch.object(repository, "generate_snapshot_id", return_value=monkeypatch_generated), + patch("src.retrieval.vdb.factory.SnapshotRepository", return_value=repository), + patch( + "src.retrieval.vdb.factory.VectorStoreFactory.get_vector_store", + return_value=_PartialWriterStore(), + ), + pytest.raises(FileNotFoundError, match="知识快照不完整"), + ): + VectorStoreFactory._load_existing_state(object(), run_config) + + assert not (snapshot_root / f".tmp-{monkeypatch_generated}").exists(), ( + "失败的 legacy 导入留下了临时快照目录" + ) + assert (snapshot_root / "ACTIVE_SNAPSHOT").read_text(encoding="utf-8") == "previous" + assert (previous / "keep.txt").read_text(encoding="utf-8") == "保留" diff --git a/tests/retrieval/vdb/test_faiss_index_text.py b/tests/retrieval/vdb/test_faiss_index_text.py index 5ff7e1b..9885762 100644 --- a/tests/retrieval/vdb/test_faiss_index_text.py +++ b/tests/retrieval/vdb/test_faiss_index_text.py @@ -98,13 +98,17 @@ def test_load_snapshot_tolerates_empty_lexical_index(tmp_path): import numpy as np + snapshot_root = tmp_path / "snapshots" + snapshot_dir = snapshot_root / "kb-empty" + snapshot_dir.mkdir(parents=True) for name, payload in (("chunks.pkl", []), ("parents.pkl", {}), ("lexical.index", [])): - with (tmp_path / name).open("wb") as file: + with (snapshot_dir / name).open("wb") as file: pickle.dump(payload, file) - np.save(tmp_path / "embeddings.npy", np.zeros((0, 4), dtype=np.float32)) + np.save(snapshot_dir / "embeddings.npy", np.zeros((0, 4), dtype=np.float32)) + (snapshot_root / "ACTIVE_SNAPSHOT").write_text("kb-empty", encoding="utf-8") store = FaissStore(file_path=None) - store.load_snapshot(str(tmp_path)) + store.load_snapshot(str(snapshot_dir), snapshot_root=snapshot_root) assert store.bm25_index is None assert store.keyword_search("任意查询") == [] diff --git a/tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py b/tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py index 329108c..c6af553 100644 --- a/tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py +++ b/tests/retrieval/vdb/test_faiss_sidecar_roundtrip.py @@ -13,6 +13,15 @@ def test_faiss_store_rejects_snapshot_without_embeddings_before_writing_npy(tmp_ assert not (tmp_path / "snapshot" / "embeddings.npy").exists() +def _activate_snapshot(snapshot_root, snapshot_id: str) -> None: + """写入 ACTIVE_SNAPSHOT 标记,让快照目录成为「本应用管理的活动快照」。 + + ``load_snapshot`` 现在会校验归属与激活状态(快照内的文件是 pickle, + 路径本身不能证明来源),所以测试必须构造出与真实运行一致的目录结构。 + """ + (snapshot_root / "ACTIVE_SNAPSHOT").write_text(snapshot_id, encoding="utf-8") + + def test_faiss_store_persists_parent_sidecar_snapshot_roundtrip(tmp_path): snapshot_dir = tmp_path / "snapshot" store = FaissStore(file_path=None) @@ -37,9 +46,10 @@ def test_faiss_store_persists_parent_sidecar_snapshot_roundtrip(tmp_path): ) store._rebuild_indices() store.save_snapshot(str(snapshot_dir)) + _activate_snapshot(tmp_path, "snapshot") reloaded = FaissStore(file_path=None) - reloaded.load_snapshot(str(snapshot_dir)) + reloaded.load_snapshot(str(snapshot_dir), snapshot_root=tmp_path) assert reloaded.resolve_parent_content("parent-1") == "parent content" assert reloaded.parent_documents["parent-1"]["metadata"]["source"] == "kb.md" @@ -54,3 +64,159 @@ def test_faiss_store_rejects_query_dimension_mismatch(): with pytest.raises(ValueError, match="维度"): store.semantic_search([0.0, 0.0, 0.0]) + + +# ── 回归:pickle 加载入口的信任边界 ── + + +def _make_valid_snapshot(snapshot_root, snapshot_id="kb-ok", *, activate=True): + """造一个最小但自洽的快照目录(1 个分块 + 1 行向量)。""" + import pickle + + snapshot_dir = snapshot_root / snapshot_id + snapshot_dir.mkdir(parents=True) + with (snapshot_dir / "chunks.pkl").open("wb") as file: + pickle.dump([{"page_content": "内容", "metadata": {"source": "s.md"}}], file) + with (snapshot_dir / "parents.pkl").open("wb") as file: + pickle.dump({}, file) + np.save(snapshot_dir / "embeddings.npy", np.zeros((1, 4), dtype=np.float32)) + # lexical.index 也要是合法 pickle:空 bytes 会让 pickle.load 抛 EOFError, + # 那是「文件被截断」而不是本节要测的信任边界。 + with (snapshot_dir / "lexical.index").open("wb") as file: + pickle.dump([["内容"]], file) + if activate: + (snapshot_root / "ACTIVE_SNAPSHOT").write_text(snapshot_id, encoding="utf-8") + return snapshot_dir + + +def test_load_snapshot_requires_trust_root(tmp_path): + """不声明 snapshot_root 时必须拒绝。 + + 快照内的 chunks.pkl / parents.pkl / lexical.index 都是 pickle,反序列化即 + 执行代码。没有信任声明时不能退化成「任意目录都可加载」,否则这个边界等于 + 不存在——那正是 AGENTS.md 要求「不导入不可信来源的 .pkl」被架空的形态。 + """ + snapshot_dir = _make_valid_snapshot(tmp_path / "snapshots") + + with pytest.raises(ValueError, match="缺少 snapshot_root"): + FaissStore(file_path=None).load_snapshot(str(snapshot_dir)) + + +def test_load_snapshot_rejects_directory_outside_trust_root(tmp_path): + """信任根之外的目录必须拒绝,即使目录结构看起来完全合法。""" + outside = tmp_path / "outside" + snapshot_dir = _make_valid_snapshot(outside) + trust_root = tmp_path / "snapshots" + trust_root.mkdir() + (trust_root / "ACTIVE_SNAPSHOT").write_text("kb-ok", encoding="utf-8") + + with pytest.raises(ValueError, match="必须位于受信根目录内"): + FaissStore(file_path=None).load_snapshot(str(snapshot_dir), snapshot_root=trust_root) + + +def test_load_snapshot_rejects_symlink_escape(tmp_path): + """信任根内的符号链接指向根外时必须拒绝(resolve 后再比归属)。""" + real_root = tmp_path / "real" + snapshot_dir = _make_valid_snapshot(real_root) + trust_root = tmp_path / "snapshots" + trust_root.mkdir() + escape = trust_root / "kb-link" + escape.symlink_to(snapshot_dir) + (trust_root / "ACTIVE_SNAPSHOT").write_text("kb-link", encoding="utf-8") + + with pytest.raises(ValueError, match="必须位于受信根目录内"): + FaissStore(file_path=None).load_snapshot(str(escape), snapshot_root=trust_root) + + +def test_load_snapshot_rejects_inactive_snapshot(tmp_path): + """在信任根之下但未被 ACTIVE_SNAPSHOT 激活的目录必须拒绝。 + + 仅「在 root 之下」不足以证明目录可用:未激活或半成品目录同样在 root 之下。 + """ + snapshot_root = tmp_path / "snapshots" + snapshot_dir = _make_valid_snapshot(snapshot_root, snapshot_id="kb-inactive", activate=False) + + with pytest.raises(ValueError, match="未被激活"): + FaissStore(file_path=None).load_snapshot(str(snapshot_dir), snapshot_root=snapshot_root) + + +def test_load_snapshot_rejects_when_marker_points_elsewhere(tmp_path): + """ACTIVE_SNAPSHOT 指向另一个快照时,加载非活动快照必须拒绝。""" + snapshot_root = tmp_path / "snapshots" + # 先造两个目录都不激活,再一次把标记写到 kb-active: + # helper 默认激活,连调两次会让第二次调用把标记覆盖成 kb-other, + # 那样被加载的目录反而成了活动快照,测不到「非活动」这条分支。 + other = _make_valid_snapshot(snapshot_root, snapshot_id="kb-other", activate=False) + _make_valid_snapshot(snapshot_root, snapshot_id="kb-active") + + with pytest.raises(ValueError, match="不是当前活动快照"): + FaissStore(file_path=None).load_snapshot(str(other), snapshot_root=snapshot_root) + + +def test_load_snapshot_accepts_activated_snapshot(tmp_path): + """收紧不能误伤:合法活动快照必须能加载并读出内容。""" + snapshot_root = tmp_path / "snapshots" + snapshot_dir = _make_valid_snapshot(snapshot_root) + + store = FaissStore(file_path=None) + store.load_snapshot(str(snapshot_dir), snapshot_root=snapshot_root) + + assert [doc["page_content"] for doc in store.documents] == ["内容"] + + +def test_load_requires_trusted_paths(tmp_path): + """legacy pickle 加载同样必须声明来源。""" + import pickle + + legacy = tmp_path / "legacy.pkl" + with legacy.open("wb") as file: + pickle.dump({"documents": [], "embeddings": None}, file) + + with pytest.raises(ValueError, match="受信来源声明"): + FaissStore(file_path=None).load(str(legacy)) + + +def test_load_rejects_untrusted_path(tmp_path): + """不在任何受信根内的 legacy 文件必须拒绝。""" + import pickle + + legacy = tmp_path / "legacy.pkl" + with legacy.open("wb") as file: + pickle.dump({"documents": [], "embeddings": None}, file) + trust_root = tmp_path / "trusted" + trust_root.mkdir() + + with pytest.raises(ValueError, match="不在任何受信路径内"): + FaissStore(file_path=None).load(str(legacy), trusted_paths=[trust_root]) + + +def test_load_accepts_trusted_path(tmp_path): + """收紧不能误伤:声明来源后必须能加载。""" + import pickle + + legacy = tmp_path / "legacy.pkl" + with legacy.open("wb") as file: + pickle.dump( + {"documents": [{"page_content": "旧数据", "metadata": {}}], "embeddings": None}, + file, + ) + + store = FaissStore(file_path=None) + store.load(str(legacy), trusted_paths=[tmp_path]) + + assert [doc["page_content"] for doc in store.documents] == ["旧数据"] + + +def test_init_with_file_path_requires_trusted_paths(tmp_path): + """``__init__(file_path=...)`` 不再隐式加载,必须先声明来源。""" + import pickle + + legacy = tmp_path / "legacy.pkl" + with legacy.open("wb") as file: + pickle.dump({"documents": [], "embeddings": None}, file) + + with pytest.raises(ValueError, match="受信来源声明"): + FaissStore(file_path=str(legacy)) + + store = FaissStore(file_path=str(legacy), trusted_paths=[tmp_path]) + assert store.documents == [] diff --git a/tests/retrieval/vdb/test_snapshot_repository.py b/tests/retrieval/vdb/test_snapshot_repository.py index fc94278..cb5a123 100644 --- a/tests/retrieval/vdb/test_snapshot_repository.py +++ b/tests/retrieval/vdb/test_snapshot_repository.py @@ -177,3 +177,135 @@ def test_validate_snapshot_dir_reports_unparseable_chunks_as_value_error(tmp_pat with pytest.raises(ValueError, match="无法解析"): repository.validate_snapshot_dir(snapshot_dir) + + +# ── 回归:manifest 的 schema 版本准入 ── + + +def test_load_manifest_rejects_unknown_schema_version(tmp_path, monkeypatch): + """未知 schema 版本必须拒绝,而不是照着当前代码去解释。 + + ``from_mapping`` 是纯解析(``str(data["schema_version"])``),不做版本判断; + 版本准入放在 ``load_manifest``。旧/新 schema 的字段语义可能已经变了 + (本项目 v1→v2 调整过分块与 embedding 的落盘结构),静默接受未知版本会得到 + 错误结果而不是报错。 + """ + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + snapshot_dir = snapshot_root / "kb-old-schema" + snapshot_dir.mkdir() + # 故意写一个当前程序不认识的 schema 版本 + (snapshot_dir / "manifest.toml").write_text( + 'schema_version = "999"\n' + 'snapshot_id = "kb-old-schema"\n' + 'created_at = "2020-01-01T00:00:00+00:00"\n' + 'store_type = "faiss"\n' + 'embedding_provider = "local-hash"\n' + 'embedding_model = "local-hash-256"\n' + 'chunk_mode = "standard"\n' + 'source_digest = "d"\n' + "document_count = 1\n" + "chunk_count = 1\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="schema") as excinfo: + repository.load_manifest(snapshot_dir) + + message = str(excinfo.value) + assert "999" in message + assert "2" in message + + +def test_load_manifest_accepts_current_schema_version(tmp_path, monkeypatch): + """收紧不能误伤:当前版本的 manifest 必须能读回。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + snapshot_dir = snapshot_root / "kb-current" + snapshot_dir.mkdir() + manifest = KnowledgeSnapshotManifest.create( + snapshot_id="kb-current", + store_type="faiss", + embedding_provider="local-hash", + embedding_model="local-hash-256", + chunk_mode="standard", + source_digest="d", + document_count=1, + chunk_count=1, + ) + repository.write_manifest(snapshot_dir, manifest) + + loaded = repository.load_manifest(snapshot_dir) + + assert loaded.schema_version == manifest.schema_version + assert loaded.snapshot_id == "kb-current" + + +def test_validate_snapshot_dir_rejects_directory_outside_root(tmp_path, monkeypatch): + """快照目录必须在受信根内:快照内的 pickle 反序列化即执行代码。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + outside = tmp_path / "elsewhere" + outside.mkdir() + + with pytest.raises(ValueError, match="受信根"): + repository.validate_snapshot_dir(outside) + + +def test_validate_snapshot_dir_rejects_symlinked_snapshot_dir(tmp_path, monkeypatch): + """快照目录本身是符号链接时必须拒绝,即使目标在根内。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + real = repository.root / "kb-real" + real.mkdir() + link = repository.root / "kb-link" + link.symlink_to(real) + + with pytest.raises(ValueError, match="符号链接"): + repository.validate_snapshot_dir(link) + + +def test_validate_snapshot_dir_rejects_nested_snapshot_dir(tmp_path, monkeypatch): + """快照目录必须是根的直接子目录,不能嵌在更深层级。""" + from src.retrieval.snapshot_repository import SnapshotRepository + + snapshot_root = tmp_path / "snapshots" + snapshot_root.mkdir() + monkeypatch.setattr("src.utils.config.ROOT_DIR", tmp_path) + (tmp_path / "config.toml").write_text('snapshot_root = "snapshots"\n', encoding="utf-8") + get_settings.cache_clear() + repository = SnapshotRepository(build_run_config(get_settings())) + + nested = repository.root / "outer" / "kb-inner" + nested.mkdir(parents=True) + + with pytest.raises(ValueError, match="直接位于快照根目录之下"): + repository.validate_snapshot_dir(nested) diff --git a/tests/test_config.py b/tests/test_config.py index af344dd..5245828 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -682,3 +682,37 @@ def test_get_settings_wraps_settings_error_from_unparseable_env_list(monkeypatch get_settings.cache_clear() assert "kb_splitter_separators" in str(excinfo.value) + + +# ── 回归:仓库自身代码路径不得产生被吞掉的警告 ── + + +def test_repository_code_paths_emit_no_swallowed_warnings(): + """走一遍本仓库的配置加载路径,断言不产生 UserWarning/DeprecationWarning。 + + ``pytest.ini`` 原先用全局 ``ignore::UserWarning`` / ``ignore::DeprecationWarning`` + 把它们静音了:实测同一个探针用例在全局忽略下静默通过、在 + ``-W error::UserWarning`` 下失败,说明警告确实发生却被吞掉。现在过滤只按 + 第三方 module 前缀限定,所以本仓库代码产生的警告必须在这里被显式盯住—— + 否则「收窄过滤」本身也会在未来某次改动里悄悄失效。 + """ + import warnings + + get_settings.cache_clear() + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + settings = get_settings() + # 触发几条常见的配置读写路径 + settings.model_dump() + build_runtime_paths = resolve_app_root() + assert build_runtime_paths is not None + finally: + get_settings.cache_clear() + + offenders = [ + f"{warning.category.__name__}: {warning.message}" + for warning in caught + if issubclass(warning.category, (UserWarning, DeprecationWarning)) + ] + assert not offenders, f"本仓库代码产生了警告,请修复而不是加过滤: {offenders}" diff --git a/tests/test_release_scripts.py b/tests/test_release_scripts.py index 9e98253..d6fd519 100644 --- a/tests/test_release_scripts.py +++ b/tests/test_release_scripts.py @@ -98,3 +98,85 @@ def test_stage_bundle_copies_package_files_including_subdirectories(monkeypatch, assert (bundle_root / relative).is_file(), relative # 子目录路径必须真的落在子目录里,而不是被拍平。 assert (bundle_root / "licenses" / "APACHE-2.0.txt").parent.name == "licenses" + + +# ── 回归:ZIP 分支对悬空符号链接的确定行为 ── + + +def test_archive_bundle_zip_rejects_dangling_symlink(tmp_path, monkeypatch): + """ZIP 分支遇到悬空符号链接必须给出可归因的 RuntimeError。 + + 实测修复前 ``ZipFile.write`` 抛的是裸 ``FileNotFoundError``,消息里只有 + 一个路径,看不出「这是发布包里的符号链接」,而 tar.gz 分支对同一目录完全 + 正常——两个分支行为不一致,且在 Windows(唯一的 zip 目标)上表现为一个难 + 以归因的构建失败。 + """ + from scripts.build_binary_release import archive_bundle + + monkeypatch.setattr("scripts.build_binary_release.ARTIFACT_ROOT", tmp_path) + bundle_root = tmp_path / "PyRAG-Kit-1.4.0-windows-x64" + bundle_root.mkdir() + (bundle_root / "real.txt").write_text("hello", encoding="utf-8") + (bundle_root / "dangling").symlink_to("nowhere.txt") + + with pytest.raises(RuntimeError, match="符号链接") as excinfo: + archive_bundle(bundle_root, "windows-x64") + + assert "dangling" in str(excinfo.value) + assert not (tmp_path / f"{bundle_root.name}.zip").exists() + + +def test_archive_bundle_zip_succeeds_without_symlinks(tmp_path, monkeypatch): + """没有悬空链接时 ZIP 必须正常生成,且能被读回。""" + import zipfile + + from scripts.build_binary_release import archive_bundle + + monkeypatch.setattr("scripts.build_binary_release.ARTIFACT_ROOT", tmp_path) + bundle_root = tmp_path / "PyRAG-Kit-1.4.0-windows-x64" + bundle_root.mkdir() + (bundle_root / "real.txt").write_text("hello", encoding="utf-8") + (bundle_root / "sub").mkdir() + (bundle_root / "sub" / "nested.txt").write_text("nested", encoding="utf-8") + + archive_path = archive_bundle(bundle_root, "windows-x64") + + assert archive_path.exists() + with zipfile.ZipFile(archive_path) as archive: + names = archive.namelist() + assert f"{bundle_root.name}/real.txt" in names + assert f"{bundle_root.name}/sub/nested.txt" in names + + +def test_archive_bundle_targz_keeps_dangling_symlink(tmp_path, monkeypatch): + """tar.gz 分支必须保持既有行为:悬空链接照原样写入归档,不报错。""" + import tarfile + + from scripts.build_binary_release import archive_bundle + + monkeypatch.setattr("scripts.build_binary_release.ARTIFACT_ROOT", tmp_path) + bundle_root = tmp_path / "PyRAG-Kit-1.4.0-linux-x64" + bundle_root.mkdir() + (bundle_root / "real.txt").write_text("hello", encoding="utf-8") + (bundle_root / "dangling").symlink_to("nowhere.txt") + + archive_path = archive_bundle(bundle_root, "linux-x64") + + assert archive_path.exists() + with tarfile.open(archive_path) as archive: + members = {member.name: member for member in archive.getmembers()} + assert f"{bundle_root.name}/dangling" in members + assert members[f"{bundle_root.name}/dangling"].issym() + + +def test_find_dangling_symlinks_ignores_valid_links_and_files(tmp_path): + """只有「是符号链接且目标不存在」才算悬空。""" + from scripts.build_binary_release import find_dangling_symlinks + + bundle_root = tmp_path / "bundle" + bundle_root.mkdir() + (bundle_root / "real.txt").write_text("hello", encoding="utf-8") + (bundle_root / "valid").symlink_to("real.txt") + (bundle_root / "dangling").symlink_to("nowhere.txt") + + assert [path.name for path in find_dangling_symlinks(bundle_root)] == ["dangling"] From b993fc479cc421489f79b0c43a930648cb337388 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 22:25:46 +0800 Subject: [PATCH 30/31] =?UTF-8?q?fix:=20=E9=98=BB=E6=AD=A2=E9=80=80?= =?UTF-8?q?=E5=87=BA=E9=92=A9=E5=AD=90=E5=88=A0=E9=99=A4=E8=A2=AB=E8=AF=AF?= =?UTF-8?q?=E9=85=8D=E7=9A=84=20cache=5Fpath=EF=BC=9B=E5=BF=BD=E7=95=A5?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=B7=A5=E4=BD=9C=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清理边界(本轮最主要的发现) - `cleanup_temp_files()` 已在 atexit 注册,每次程序正常退出都会对 `settings.cache_path` 执行 `shutil.rmtree`,但删除前没有任何安全判断,而 `cache_path` 是用户可配置项且校验只做相对路径转绝对路径。 - 实测 `Settings(cache_path="/")`、`"/etc"`、`"/usr"`、`"~"`、`"/var"`、 `"/System"`、`"../.."` 全部被接受,随后整个目录被删掉——用 tmp 目录复现确认 内容确实消失。这是不可逆的数据丢失,且触发条件是「配置文件写错一个值」。 - 新增 `_reject_unsafe_cache_dir()`:拒绝文件系统根目录、家目录本身、以及 `/etc`、`/usr`、`/bin`、`/sbin`、`/var`、`/System`、`/Library`、`/Applications` 这些系统目录,拒绝原因写入日志。 - 判定只看「是否危险」而不看「目录名是不是 .cache」:用户可能有意把缓存放到 `data/cache`,按名字判断会误伤(已加用例守住这一点)。 - 顺带补上「路径存在但不是目录」的分支(原先会把文件路径交给 rmtree)。 本地工作文件 - `HANDOFF.md` 加入 .gitignore:按约定不入库,且它可能含 API Key、令牌等敏感 信息,只作为本地交接备忘。 - `.review-*.html` 加入 .gitignore:审查过程生成的一次性复核报告(按快照哈希命名)。 - 两个文件仍在磁盘上,只是不再出现在 `git status` 里。 覆盖补充(按「覆盖核心即可」的原则,只补真正缺的核心路径) - 新增 `tests/test_cleanup.py`(15 条):这是原先零覆盖的 atexit 删除路径, 失败后果不可逆,属于必须有人守的核心边界。 - 盘点后确认另外三个未被测试引用的模块不值得测:`src/ui/config_menu.py`(260 行 questionary 交互胶水)、`src/ui/display_utils.py`(纯格式化)、 `src/retrieval_test/excel_logger.py`(召回测试输出,非核心链路)。 - 其中两条夹具测试(用 tmp_path 冒充文件系统根/家目录)写完发现不可能成立—— 真实 `Path` 的 `anchor` 恒为 `/`,无法伪造——已删除,根目录与家目录判定改由 直接针对 `_reject_unsafe_cache_dir` 的参数化用例覆盖。 质量门(全部实跑):pytest 1262 passed、ruff format/check、mypy 49 files、 bandit -ll、compileall、uv lock --check、git diff --check。 端到端:`main.py --smoke-test` 正常退出,安全判断不误伤正常缓存目录。 --- .gitignore | 6 +++ src/utils/cleanup.py | 83 +++++++++++++++++++++++++++++------- tests/test_cleanup.py | 97 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 tests/test_cleanup.py diff --git a/.gitignore b/.gitignore index f56791b..c87fd3a 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,9 @@ config.toml # 缓存目录 .cache/ + +# 会话/审查中间产物(本地工作文件,不入库) +# HANDOFF.md 可能含 API Key、令牌等敏感信息,只作为本地交接备忘。 +HANDOFF.md +# 审查过程生成的复核报告(快照哈希命名,属于一次性产物) +.review-*.html diff --git a/src/utils/cleanup.py b/src/utils/cleanup.py index ab83735..334daba 100644 --- a/src/utils/cleanup.py +++ b/src/utils/cleanup.py @@ -1,6 +1,7 @@ import atexit import os import shutil +from pathlib import Path from .config import get_settings # 导入 get_settings 函数 from .log_manager import get_module_logger # 导入日志管理器 @@ -9,29 +10,81 @@ logger = get_module_logger(__name__) # 获取当前模块的日志器 -def cleanup_temp_files(): +def _reject_unsafe_cache_dir(cache_dir: str) -> str | None: + """判断 cache 目录是否可安全删除;不可删时返回原因,可删返回 None。 + + ``cache_path`` 是用户可配置项,而 ``shutil.rmtree`` 的后果不可逆:实测 + ``Settings(cache_path="/")``、``"/etc"``、``"~"``、``"../.."`` 全部被接受, + 随后 ``cleanup_temp_files()`` 会把整个目录删掉——它已在 atexit 注册,每次 + 程序正常退出都会执行。 + + 这里只做最低限度的常识判断,不试图猜用户意图:根目录、家目录、以及常见的 + 系统目录一律不删,并把判定结果写进日志。 """ - 在程序退出时清理由本程序创建的 .cache 目录。 + if not cache_dir: + return "cache_path 为空" + resolved = Path(cache_dir).resolve() + if resolved == Path(resolved.anchor): + return f"{resolved} 是文件系统根目录" + home = Path.home().resolve() + if resolved == home: + return f"{resolved} 是当前用户的家目录" + protected_roots = [ + Path("/etc"), + Path("/usr"), + Path("/bin"), + Path("/sbin"), + Path("/var"), + Path("/System"), + Path("/Library"), + Path("/Applications"), + ] + for protected in protected_roots: + if resolved == protected.resolve(): + return f"{resolved} 是系统目录" + # 目录名不是 .cache 时只警告不阻断:用户可能有意把缓存放到别处 + # (例如 config.toml 里写 cache_path = "data/cache")。 + return None + + +def cleanup_temp_files(): + """在程序退出时清理由本程序创建的缓存目录。 + + 删除前先做安全判断:``cache_path`` 可被用户配置成任意路径,而这里的 + ``rmtree`` 在 atexit 里执行、后果不可逆,不能无条件照做。 """ logger.info("执行退出前清理任务...") - # 从 get_settings() 获取缓存路径 current_settings = get_settings() cache_dir = current_settings.cache_path - if os.path.exists(cache_dir): - try: - shutil.rmtree(cache_dir) - logger.info(f"已成功删除缓存目录: {cache_dir}") - except OSError as e: - error_text = redact_sensitive_text(str(e)) - logger.exception( - "删除缓存目录 %s 时出错: %s", - cache_dir, - error_text, - ) - else: + unsafe_reason = _reject_unsafe_cache_dir(cache_dir) + if unsafe_reason is not None: + logger.error( + "拒绝清理缓存目录,因其不是安全的删除目标: %s(%s)。 请检查配置项 cache_path。", + cache_dir, + unsafe_reason, + ) + return + + if not os.path.exists(cache_dir): logger.info("未找到 .cache 目录,无需清理。") + return + + if not os.path.isdir(cache_dir): + logger.warning("缓存路径不是目录,跳过清理: %s", cache_dir) + return + + try: + shutil.rmtree(cache_dir) + logger.info(f"已成功删除缓存目录: {cache_dir}") + except OSError as e: + error_text = redact_sensitive_text(str(e)) + logger.exception( + "删除缓存目录 %s 时出错: %s", + cache_dir, + error_text, + ) # 注册函数,使其在程序正常退出时被调用 diff --git a/tests/test_cleanup.py b/tests/test_cleanup.py new file mode 100644 index 0000000..e930002 --- /dev/null +++ b/tests/test_cleanup.py @@ -0,0 +1,97 @@ +"""``cleanup_temp_files`` 是 atexit 钩子,删除前必须挡住危险路径。 + +``cache_path`` 是用户可配置项,而 ``shutil.rmtree`` 的后果不可逆:实测 +``Settings(cache_path="/")``、``"/etc"``、``"~"``、``"../.."`` 全部被 pydantic +接受,随后退出钩子会把整个目录删掉。这里守的是「删错目录」这一类无法回滚的失败。 +""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from src.utils.cleanup import _reject_unsafe_cache_dir, cleanup_temp_files + + +@pytest.mark.parametrize( + "dangerous", + ["/", "/etc", "/usr", "/var", "/System", "/Library", "/bin", "/sbin"], +) +def test_reject_unsafe_cache_dir_rejects_system_paths(dangerous): + """文件系统根目录与常见系统目录一律拒绝。""" + assert _reject_unsafe_cache_dir(dangerous) is not None + + +def test_reject_unsafe_cache_dir_rejects_home_directory(): + """家目录本身拒绝(但不连带拒绝家目录下的子目录)。""" + assert _reject_unsafe_cache_dir(str(Path.home())) is not None + + +def test_reject_unsafe_cache_dir_rejects_empty_value(): + assert _reject_unsafe_cache_dir("") is not None + + +def test_reject_unsafe_cache_dir_allows_ordinary_cache_dir(tmp_path): + """收紧不能误伤:非默认名的普通缓存目录仍可清理。 + + 用户可能有意把缓存放到别处(``config.toml`` 里写 + ``cache_path = "data/cache"``),判定不能只看目录名是不是 ``.cache``。 + """ + ordinary = tmp_path / "data" / "cache" + ordinary.mkdir(parents=True) + assert _reject_unsafe_cache_dir(str(ordinary)) is None + + +def test_cleanup_temp_files_removes_ordinary_cache_dir(tmp_path): + """正常路径必须照常清理,功能不能被安全判断挡住。""" + cache = tmp_path / ".cache" + cache.mkdir() + (cache / "entry.txt").write_text("c", encoding="utf-8") + + with patch("src.utils.cleanup.get_settings", return_value=_settings_with(str(cache))): + cleanup_temp_files() + + assert not cache.exists() + + +def test_cleanup_temp_files_skips_missing_dir(tmp_path): + """目录不存在时是正常路径,不应报错。""" + missing = tmp_path / "absent" + + with patch("src.utils.cleanup.get_settings", return_value=_settings_with(str(missing))): + cleanup_temp_files() + + assert not missing.exists() + + +def test_cleanup_temp_files_skips_non_directory(tmp_path): + """cache_path 指向文件时跳过,不能把它当目录删。""" + target = tmp_path / "not_a_dir.txt" + target.write_text("data", encoding="utf-8") + + with patch("src.utils.cleanup.get_settings", return_value=_settings_with(str(target))): + cleanup_temp_files() + + assert target.read_text(encoding="utf-8") == "data" + + +def _settings_with(cache_path: str): + """构造只带 cache_path 的最小 settings 替身。""" + from types import SimpleNamespace + + return SimpleNamespace(cache_path=cache_path) + + +def test_cleanup_temp_files_handles_oserror_gracefully(tmp_path): + """删除失败只记日志、不抛异常:这是退出路径,抛错会打断解释器收尾。""" + cache = tmp_path / ".cache" + cache.mkdir() + (cache / "x.txt").write_text("x", encoding="utf-8") + + with ( + patch("src.utils.cleanup.get_settings", return_value=_settings_with(str(cache))), + patch("src.utils.cleanup.shutil.rmtree", side_effect=OSError("权限不足")), + ): + cleanup_temp_files() + + assert cache.exists() From c460e33ec32438106a16509a6e57c64cbcd81f59 Mon Sep 17 00:00:00 2001 From: Mison Date: Wed, 23 Sep 2026 22:33:19 +0800 Subject: [PATCH 31/31] =?UTF-8?q?docs:=20=E5=90=8C=E6=AD=A5=20CHANGELOG=20?= =?UTF-8?q?=E7=9A=84=E6=B5=8B=E8=AF=95=E6=80=BB=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试总数此前记录为 1119,实际已增至 1262(b424bb3 起四轮修复新增的回归断言)。 数值取自 `uv run pytest -q --collect-only` 的实际收集数。 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ae3e81..cb601c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ ### 测试与文档 -- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1119。新增断言经红绿验证(回退对应源码后测试变红);其中一部分是**反向守卫**——回退源码不会变红,需要定向变异(例如「让守卫连 Ark 一起拒」)才能验证,这类断言守的是「不能过度收紧」,同样有区分度。 +- 新增 Provider 协议适配、SDK 能力、凭证边界、Rerank 契约与失败语义回归测试;测试总数增至 1262。新增断言经红绿验证(回退对应源码后测试变红);其中一部分是**反向守卫**——回退源码不会变红,需要定向变异(例如「让守卫连 Ark 一起拒」)才能验证,这类断言守的是「不能过度收紧」,同样有区分度。 - 修复三处测试有效性缺陷:掩码尾部可见片段的断言用 `startswith("[REDACTED]")`,而掩码前缀在任何实现下都会被替换成 `[REDACTED]`,该断言恒真——尾部片段原样泄漏时也能通过,改为全文相等;短纯字母凭证的 7 个用例里有 4 个取值 ≥12 字符,旧规则本就能命中、对被测属性零区分度,改为真正短于 12 字符的值并断言值本身被替换;掩码线性度用例用单次采样配 100ms 阈值,实测本机 p99.9 即到 100ms、满载时更高,会间歇误报,改为放大规模到线性与二次相差三个数量级、取多轮最小值。 - 为活动快照的 embedding 兼容性检测补充回归测试:快照记录的 embedding provider 或模型名与当前运行配置不一致时必须显式失败,避免用错向量空间后静默产出错误检索结果。 - 引入 Ruff、Bandit 与 MyPy 到开发依赖,并补齐对应配置;新增 `.github/workflows/quality.yml`,在 PR 与 `main` 推送时执行格式化检查、lint、类型检查、安全扫描与完整测试,此前这些工具只在本地手动运行。