whosellm 0.3.1__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. {whosellm-0.3.1 → whosellm-0.4.0}/.bumpversion.toml +1 -1
  2. {whosellm-0.3.1 → whosellm-0.4.0}/CHANGELOG.md +10 -0
  3. {whosellm-0.3.1 → whosellm-0.4.0}/PKG-INFO +30 -1
  4. {whosellm-0.3.1 → whosellm-0.4.0}/README.md +29 -0
  5. {whosellm-0.3.1 → whosellm-0.4.0}/docs/add_new_model_family.md +13 -0
  6. {whosellm-0.3.1 → whosellm-0.4.0}/pyproject.toml +1 -1
  7. whosellm-0.4.0/tests/test_tool_result_surfaces.py +279 -0
  8. {whosellm-0.3.1 → whosellm-0.4.0}/uv.lock +1 -1
  9. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/__init__.py +14 -2
  10. whosellm-0.4.0/whosellm/capabilities.py +238 -0
  11. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/alibaba.py +31 -1
  12. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/anthropic.py +93 -1
  13. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/deepseek/deepseek_official.py +67 -1
  14. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/gemini.py +138 -1
  15. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_3_5.py +4 -0
  16. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_4.py +4 -0
  17. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_4_1.py +10 -0
  18. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_4o.py +14 -0
  19. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_5.py +14 -0
  20. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_5_1.py +11 -0
  21. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_5_2.py +12 -0
  22. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_5_3.py +4 -0
  23. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_5_4.py +16 -0
  24. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_5_5.py +10 -0
  25. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_5_6.py +10 -0
  26. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_gpt_6.py +7 -0
  27. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_o1.py +10 -0
  28. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_o3.py +14 -0
  29. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/openai_o4.py +10 -0
  30. whosellm-0.4.0/whosellm/models/families/openai/surfaces.py +70 -0
  31. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/zhipu.py +49 -1
  32. whosellm-0.3.1/whosellm/capabilities.py +0 -114
  33. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/commands/interview.md +0 -0
  34. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/projects/-Users-jqq-PycharmProjects-llmeta/memory/MEMORY.md +0 -0
  35. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/projects/-Users-jqq-PycharmProjects-llmeta/memory/feedback_use_uv.md +0 -0
  36. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/settings.json +0 -0
  37. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/code-review/SKILL.md +0 -0
  38. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/create-skill/SKILL.md +0 -0
  39. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/e2e-metadata/SKILL.md +0 -0
  40. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/evolve/SKILL.md +0 -0
  41. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/fix-review/SKILL.md +0 -0
  42. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/release/SKILL.md +0 -0
  43. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/review-provider-model/SKILL.md +0 -0
  44. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/review-provider-model/providers/alibaba.md +0 -0
  45. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/review-provider-model/providers/anthropic.md +0 -0
  46. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/review-provider-model/providers/deepseek.md +0 -0
  47. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/review-provider-model/providers/gemini.md +0 -0
  48. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/review-provider-model/providers/openai.md +0 -0
  49. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/review-provider-model/providers/others.md +0 -0
  50. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/review-provider-model/providers/vidu.md +0 -0
  51. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/review-provider-model/providers/zhipu.md +0 -0
  52. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/update-provider-model/SKILL.md +0 -0
  53. {whosellm-0.3.1 → whosellm-0.4.0}/.claude/skills/update-provider-model/testing.md +0 -0
  54. {whosellm-0.3.1 → whosellm-0.4.0}/.coveragerc +0 -0
  55. {whosellm-0.3.1 → whosellm-0.4.0}/.github/workflows/publish.yml +0 -0
  56. {whosellm-0.3.1 → whosellm-0.4.0}/.github/workflows/tests.yml +0 -0
  57. {whosellm-0.3.1 → whosellm-0.4.0}/.gitignore +0 -0
  58. {whosellm-0.3.1 → whosellm-0.4.0}/.windsurf/workflows/addmodel.md +0 -0
  59. {whosellm-0.3.1 → whosellm-0.4.0}/.windsurf/workflows/arch.md +0 -0
  60. {whosellm-0.3.1 → whosellm-0.4.0}/.windsurf/workflows/testllmeta.md +0 -0
  61. {whosellm-0.3.1 → whosellm-0.4.0}/CLAUDE.md +0 -0
  62. {whosellm-0.3.1 → whosellm-0.4.0}/LICENSE +0 -0
  63. {whosellm-0.3.1 → whosellm-0.4.0}/docs/refactor_proposal.md +0 -0
  64. {whosellm-0.3.1 → whosellm-0.4.0}/docs/spec_model_family_redesign.md +0 -0
  65. {whosellm-0.3.1 → whosellm-0.4.0}/examples/advanced_usage.py +0 -0
  66. {whosellm-0.3.1 → whosellm-0.4.0}/examples/basic_usage.py +0 -0
  67. {whosellm-0.3.1 → whosellm-0.4.0}/mypy.ini +0 -0
  68. {whosellm-0.3.1 → whosellm-0.4.0}/pytest.ini +0 -0
  69. {whosellm-0.3.1 → whosellm-0.4.0}/ruff.toml +0 -0
  70. {whosellm-0.3.1 → whosellm-0.4.0}/tests/__init__.py +0 -0
  71. {whosellm-0.3.1 → whosellm-0.4.0}/tests/e2e/__init__.py +0 -0
  72. {whosellm-0.3.1 → whosellm-0.4.0}/tests/e2e/conftest.py +0 -0
  73. {whosellm-0.3.1 → whosellm-0.4.0}/tests/e2e/test_anthropic.py +0 -0
  74. {whosellm-0.3.1 → whosellm-0.4.0}/tests/e2e/test_deepseek.py +0 -0
  75. {whosellm-0.3.1 → whosellm-0.4.0}/tests/e2e/test_google.py +0 -0
  76. {whosellm-0.3.1 → whosellm-0.4.0}/tests/e2e/test_openai.py +0 -0
  77. {whosellm-0.3.1 → whosellm-0.4.0}/tests/e2e/test_zhipu.py +0 -0
  78. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/__init__.py +0 -0
  79. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/__init__.py +0 -0
  80. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_anthropic.py +0 -0
  81. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_deepseek.py +0 -0
  82. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_deepseek_tencent.py +0 -0
  83. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_gemini.py +0 -0
  84. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_glm41v_thinking.py +0 -0
  85. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_glm45.py +0 -0
  86. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_glm45v.py +0 -0
  87. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_glm46.py +0 -0
  88. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_glm46v.py +0 -0
  89. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_glm5.py +0 -0
  90. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_glm53.py +0 -0
  91. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_glm5v_turbo.py +0 -0
  92. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_gpt3_5.py +0 -0
  93. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_gpt4_1.py +0 -0
  94. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_gpt4o.py +0 -0
  95. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_gpt5.py +0 -0
  96. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_gpt6.py +0 -0
  97. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_media_count_limit_config.py +0 -0
  98. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_o1.py +0 -0
  99. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_o3.py +0 -0
  100. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_o4.py +0 -0
  101. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_qwen.py +0 -0
  102. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_qwen3_vl_models.py +0 -0
  103. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_qwen_ollama.py +0 -0
  104. {whosellm-0.3.1 → whosellm-0.4.0}/tests/models/families/test_qwen_plus.py +0 -0
  105. {whosellm-0.3.1 → whosellm-0.4.0}/tests/test_auto_register.py +0 -0
  106. {whosellm-0.3.1 → whosellm-0.4.0}/tests/test_claude_4x_versions.py +0 -0
  107. {whosellm-0.3.1 → whosellm-0.4.0}/tests/test_llmeta.py +0 -0
  108. {whosellm-0.3.1 → whosellm-0.4.0}/tests/test_media_count_limit.py +0 -0
  109. {whosellm-0.3.1 → whosellm-0.4.0}/tests/test_model_version.py +0 -0
  110. {whosellm-0.3.1 → whosellm-0.4.0}/tests/test_provider.py +0 -0
  111. {whosellm-0.3.1 → whosellm-0.4.0}/tests/test_registry_merge.py +0 -0
  112. {whosellm-0.3.1 → whosellm-0.4.0}/tests/test_specific_patterns.py +0 -0
  113. {whosellm-0.3.1 → whosellm-0.4.0}/tests/test_variant_priority_config.py +0 -0
  114. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/model_version.py +0 -0
  115. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/__init__.py +0 -0
  116. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/base.py +0 -0
  117. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/config.py +0 -0
  118. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/dynamic_enum.py +0 -0
  119. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/__init__.py +0 -0
  120. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/deepseek/__init__.py +0 -0
  121. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/deepseek/tencent.py +0 -0
  122. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/openai/__init__.py +0 -0
  123. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/others.py +0 -0
  124. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/families/vidu.py +0 -0
  125. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/patterns.py +0 -0
  126. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/models/registry.py +0 -0
  127. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/provider.py +0 -0
  128. {whosellm-0.3.1 → whosellm-0.4.0}/whosellm/py.typed +0 -0
@@ -2,7 +2,7 @@
2
2
  # 文档 / Documentation: https://callowayproject.github.io/bump-my-version/
3
3
 
4
4
  [tool.bumpversion]
5
- current_version = "0.3.1"
5
+ current_version = "0.4.0"
6
6
  parse = """(?x)
7
7
  (?P<major>0|[1-9]\\d*)\\.
8
8
  (?P<minor>0|[1-9]\\d*)\\.
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ### Added
8
+ - **工具结果多模态能力(TFROB-856)**:新增 `LLMApiSurface`(str 枚举:`claude_messages` / `openai_chat_completions` / `openai_responses` / `gemini` / `dashscope` / `glm` / `deepseek` / `deepseek_responses`)与 `ToolResultSurfaceCapability`(`modalities` / `max_inline_bytes` / `mime_types`)。`ModelCapabilities.tool_result_surfaces` 以 surface 为键登记工具结果的原生媒体能力:缺键 = 未声明,空元组 = 明确不支持。
9
+ - 新增文档上限字段:`max_pdf_size_mb`、`max_pdf_pages`、`max_document_size_mb`(预留)、`supported_document_mime_type`。`None` 表示未声明(未知),不代表“不限”;单文件与请求级上限分层登记。
10
+ - 新增单请求内联媒体总预算 `max_inline_media_bytes` 与文件类媒体合计上限 `max_inline_file_bytes`(媒体计数字段继续复用 `media_count_limit`)。
11
+ - 预留 `FileApiCapability` / `ModelCapabilities.file_api`,本版本不消费。
12
+ - 数据登记(官方文档核实 + 2026-10-29 抓取日期;未逐模型 API 实测的已显式标注):Claude 4.x/5 工具结果支持 image + PDF;OpenAI Chat Completions tool 消息 text-only、Responses `function_call_output` 支持 image/file;Gemini 3 系支持 image,Gemini 2.x 显式标为不支持;DeepSeek Chat Completions tool 消息 text-only,Responses `function_call_output` 支持 image,PDF `input_file` 经 2026-10-29 实测为静默不投递(登记为不支持)。DashScope / GLM 的官方 API 参考将 tool 消息 content 定义为字符串,登记为显式不支持(空元组)。
13
+ - 修正 OpenAI gpt-5.1 及更新视觉模型缺失的 `supports_pdf=True`(官方 PDF 解析要求视觉模型,gpt-4o 及更新)。
14
+
5
15
  ## [0.2.4] - Unreleased
6
16
 
7
17
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: whosellm
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: A unified LLM model version and capability management library
5
5
  Author-email: JQQ <jqq1716@gmail.com>
6
6
  License: MIT
@@ -146,6 +146,35 @@ print(model.capabilities.max_video_size_mb) # 20.0
146
146
  validated_params = model.validate_params(your_params)
147
147
  ```
148
148
 
149
+ ### 工具结果的多模态能力 / Multimodal Tool Results
150
+
151
+ 工具结果能否原生承载图片/文档,取决于 **provider × API surface**,而不是模型名字前缀。能力数据以 `LLMApiSurface` 为键,缺键表示「未声明」,空元组表示「明确不支持」——两者都应按不支持处理。
152
+
153
+ Whether a tool result can natively carry images/documents depends on **provider × API surface**, not on a model-name prefix. Capability data is keyed by `LLMApiSurface`; a missing key means "undeclared" and an empty tuple means "explicitly unsupported" — treat both as unsupported.
154
+
155
+ ```python
156
+ from whosellm import LLMApiSurface, LLMeta
157
+
158
+ caps = LLMeta("claude-opus-5-5").capabilities
159
+ surface = caps.tool_result_surfaces.get(LLMApiSurface.CLAUDE_MESSAGES)
160
+ print(surface.modalities) # ("image", "document")
161
+ print(surface.supports("document")) # True
162
+
163
+ # Gemini 3 系支持;2.5 系明确不支持(会返回 400)
164
+ print(LLMeta("gemini-3.8-flash").capabilities.tool_result_surfaces[LLMApiSurface.GEMINI].modalities) # ("image",)
165
+ print(LLMeta("gemini-2.5-pro").capabilities.tool_result_surfaces[LLMApiSurface.GEMINI].modalities) # ()
166
+
167
+ # DeepSeek 的 Chat Completions 与 Responses 是两个 surface:
168
+ # Chat Completions tool 消息只能放文本;Responses 的 function_call_output 支持图片,
169
+ # 但 PDF input_file 实测被静默丢弃(2026-10-29 探针)
170
+ print(LLMeta("deepseek-flash").capabilities.tool_result_surfaces[LLMApiSurface.DEEPSEEK].modalities) # ()
171
+ print(LLMeta("deepseek-flash").capabilities.tool_result_surfaces[LLMApiSurface.DEEPSEEK_RESPONSES].modalities) # ("image",)
172
+ ```
173
+
174
+ 用户消息能力仍由 `supports_vision` / `supports_audio` / `supports_video` / `supports_pdf` 表示,工具结果能力只走 `tool_result_surfaces`。文档上限见 `max_pdf_size_mb`、`max_pdf_pages`、`supported_document_mime_type`;单请求内联媒体预算见 `max_inline_media_bytes`,文件类媒体的请求级合计上限见 `max_inline_file_bytes`。
175
+
176
+ User-message capability remains on `supports_vision` / `supports_audio` / `supports_video` / `supports_pdf`; tool-result capability lives only in `tool_result_surfaces`. Document limits are exposed via `max_pdf_size_mb`, `max_pdf_pages`, and `supported_document_mime_type`; the per-request inline-media budget is `max_inline_media_bytes`, and the request-level combined cap for file-type media is `max_inline_file_bytes`.
177
+
149
178
  ### 非法名称也不会炸 / Lenient on Unknown Names
150
179
 
151
180
  我们不想打断你的流程,即便名称不在我们的数据库里也能返回一个「未知模型占位符」。
@@ -121,6 +121,35 @@ print(model.capabilities.max_video_size_mb) # 20.0
121
121
  validated_params = model.validate_params(your_params)
122
122
  ```
123
123
 
124
+ ### 工具结果的多模态能力 / Multimodal Tool Results
125
+
126
+ 工具结果能否原生承载图片/文档,取决于 **provider × API surface**,而不是模型名字前缀。能力数据以 `LLMApiSurface` 为键,缺键表示「未声明」,空元组表示「明确不支持」——两者都应按不支持处理。
127
+
128
+ Whether a tool result can natively carry images/documents depends on **provider × API surface**, not on a model-name prefix. Capability data is keyed by `LLMApiSurface`; a missing key means "undeclared" and an empty tuple means "explicitly unsupported" — treat both as unsupported.
129
+
130
+ ```python
131
+ from whosellm import LLMApiSurface, LLMeta
132
+
133
+ caps = LLMeta("claude-opus-5-5").capabilities
134
+ surface = caps.tool_result_surfaces.get(LLMApiSurface.CLAUDE_MESSAGES)
135
+ print(surface.modalities) # ("image", "document")
136
+ print(surface.supports("document")) # True
137
+
138
+ # Gemini 3 系支持;2.5 系明确不支持(会返回 400)
139
+ print(LLMeta("gemini-3.8-flash").capabilities.tool_result_surfaces[LLMApiSurface.GEMINI].modalities) # ("image",)
140
+ print(LLMeta("gemini-2.5-pro").capabilities.tool_result_surfaces[LLMApiSurface.GEMINI].modalities) # ()
141
+
142
+ # DeepSeek 的 Chat Completions 与 Responses 是两个 surface:
143
+ # Chat Completions tool 消息只能放文本;Responses 的 function_call_output 支持图片,
144
+ # 但 PDF input_file 实测被静默丢弃(2026-10-29 探针)
145
+ print(LLMeta("deepseek-flash").capabilities.tool_result_surfaces[LLMApiSurface.DEEPSEEK].modalities) # ()
146
+ print(LLMeta("deepseek-flash").capabilities.tool_result_surfaces[LLMApiSurface.DEEPSEEK_RESPONSES].modalities) # ("image",)
147
+ ```
148
+
149
+ 用户消息能力仍由 `supports_vision` / `supports_audio` / `supports_video` / `supports_pdf` 表示,工具结果能力只走 `tool_result_surfaces`。文档上限见 `max_pdf_size_mb`、`max_pdf_pages`、`supported_document_mime_type`;单请求内联媒体预算见 `max_inline_media_bytes`,文件类媒体的请求级合计上限见 `max_inline_file_bytes`。
150
+
151
+ User-message capability remains on `supports_vision` / `supports_audio` / `supports_video` / `supports_pdf`; tool-result capability lives only in `tool_result_surfaces`. Document limits are exposed via `max_pdf_size_mb`, `max_pdf_pages`, and `supported_document_mime_type`; the per-request inline-media budget is `max_inline_media_bytes`, and the request-level combined cap for file-type media is `max_inline_file_bytes`.
152
+
124
153
  ### 非法名称也不会炸 / Lenient on Unknown Names
125
154
 
126
155
  我们不想打断你的流程,即便名称不在我们的数据库里也能返回一个「未知模型占位符」。
@@ -197,6 +197,19 @@ __all__ = [
197
197
  | `max_image_pixels` | `tuple[int, int] \| None` | 最大图片像素(宽, 高) / Max image pixels (width, height) |
198
198
  | `max_video_size_mb` | `float \| None` | 最大视频大小(MB) / Max video size in MB |
199
199
  | `max_video_duration_seconds` | `int \| None` | 最大视频时长(秒) / Max video duration in seconds |
200
+ | `media_count_limit` | `MediaCountLimit` | 单请求媒体数量上限 / Per-request media count caps |
201
+ | `max_inline_media_bytes` | `int \| None` | 单请求内联媒体字节总预算 / Per-request inline-media byte budget |
202
+ | `max_inline_file_bytes` | `int \| None` | 单请求文件类媒体字节合计上限 / Combined per-request file-type media byte cap |
203
+ | `max_pdf_size_mb` | `float \| None` | PDF 大小上限(MB) / Max PDF size in MB |
204
+ | `max_pdf_pages` | `int \| None` | PDF 页数上限 / Max PDF pages per request |
205
+ | `max_document_size_mb` | `float \| None` | 非 PDF 文档大小上限(MB,预留) / Max non-PDF document size |
206
+ | `supported_document_mime_type` | `list[str]` | 支持的文档 MIME / Supported document MIME types |
207
+ | `tool_result_surfaces` | `dict[LLMApiSurface, ToolResultSurfaceCapability]` | 工具结果按 API surface 的原生媒体能力;缺键 = 未声明,空元组 = 明确不支持 / Native tool-result media capability per surface; missing = undeclared, empty tuple = unsupported |
208
+ | `file_api` | `FileApiCapability \| None` | 文件 API 能力(预留)/ File API capability (reserved) |
209
+
210
+ 工具结果能力是 **provider × API surface** 维度,而不是模型维度。新增模型时应按官方文档登记 `tool_result_surfaces`,并注明来源与抓取日期;未查证的 surface 请保持缺键(内核按不支持处理),不要乐观填写。
211
+
212
+ Tool-result capability is **provider × API surface**, not per-model. When adding models, register `tool_result_surfaces` from official docs with source and fetch date; leave unverified surfaces undeclared (consumers treat them as unsupported) instead of guessing.
200
213
 
201
214
  ---
202
215
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "whosellm"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = "A unified LLM model version and capability management library"
5
5
  authors = [
6
6
  {name = "JQQ", email = "jqq1716@gmail.com"}
@@ -0,0 +1,279 @@
1
+ # filename: test_tool_result_surfaces.py
2
+ """
3
+ 工具结果能力与文档限制测试 / Tool-result capability and document-limit tests
4
+
5
+ 覆盖 W2–W5 新增字段:
6
+ - LLMApiSurface 词表与 ToolResultSurfaceCapability
7
+ - ModelCapabilities.tool_result_surfaces / max_inline_media_bytes
8
+ - max_pdf_size_mb / max_pdf_pages / supported_document_mime_type
9
+ - FileApiCapability(预留)
10
+
11
+ 数据来源与抓取日期见 whosellm/models/families 下各配置文件的注释。
12
+ Data sources and fetch dates are documented in the family config modules.
13
+ """
14
+
15
+ import json
16
+ from dataclasses import FrozenInstanceError
17
+
18
+ import pytest
19
+
20
+ from whosellm import (
21
+ FileApiCapability,
22
+ LLMApiSurface,
23
+ LLMeta,
24
+ ModelCapabilities,
25
+ ToolResultSurfaceCapability,
26
+ )
27
+
28
+ REQUIRED_SURFACES = {
29
+ "claude_messages",
30
+ "openai_chat_completions",
31
+ "openai_responses",
32
+ "gemini",
33
+ "dashscope",
34
+ "glm",
35
+ "deepseek",
36
+ "deepseek_responses",
37
+ }
38
+
39
+
40
+ class TestLLMApiSurface:
41
+ """API surface 词表 / API surface vocabulary"""
42
+
43
+ def test_required_members_exist(self) -> None:
44
+ assert {surface.value for surface in LLMApiSurface} == REQUIRED_SURFACES
45
+
46
+ def test_str_enum_serializes_as_plain_string(self) -> None:
47
+ """str 枚举可直接作为 dict 键并 JSON 序列化 / str enum works as a dict key and serializes"""
48
+ assert LLMApiSurface.CLAUDE_MESSAGES == "claude_messages"
49
+ assert json.dumps({LLMApiSurface.CLAUDE_MESSAGES: "ok"}) == '{"claude_messages": "ok"}'
50
+
51
+ def test_value_lookup(self) -> None:
52
+ """可用字符串值还原枚举成员 / Members can be reconstructed from their string values"""
53
+ assert LLMApiSurface("claude_messages") is LLMApiSurface.CLAUDE_MESSAGES
54
+ assert str(LLMApiSurface.GEMINI) == "gemini"
55
+
56
+
57
+ class TestDefaults:
58
+ """新增字段的空默认值 / Empty defaults for the new fields"""
59
+
60
+ def test_tool_result_surface_defaults(self) -> None:
61
+ surface = ToolResultSurfaceCapability()
62
+ assert surface.modalities == ()
63
+ assert surface.max_inline_bytes is None
64
+ assert surface.mime_types is None
65
+ assert surface.supports("image") is False
66
+
67
+ def test_model_capabilities_defaults(self) -> None:
68
+ caps = ModelCapabilities()
69
+ assert caps.tool_result_surfaces == {}
70
+ assert caps.max_inline_media_bytes is None
71
+ assert caps.max_inline_file_bytes is None
72
+ assert caps.max_pdf_size_mb is None
73
+ assert caps.max_pdf_pages is None
74
+ assert caps.max_document_size_mb is None
75
+ assert caps.supported_document_mime_type == []
76
+ assert caps.file_api is None
77
+
78
+ def test_file_api_defaults(self) -> None:
79
+ file_api = FileApiCapability()
80
+ assert file_api.supports_upload is False
81
+ assert file_api.max_file_size_mb is None
82
+ assert file_api.supported_mime_types == []
83
+
84
+ def test_surface_is_frozen(self) -> None:
85
+ surface = ToolResultSurfaceCapability(modalities=("image",))
86
+ with pytest.raises(FrozenInstanceError):
87
+ surface.modalities = ("audio",) # type: ignore[misc]
88
+
89
+
90
+ class TestClaudeToolResults:
91
+ """Claude Messages:工具结果原生支持 image + document / Claude Messages tool results"""
92
+
93
+ @pytest.mark.parametrize(
94
+ ("model_name", "expected_pages"),
95
+ [
96
+ ("claude-opus-5-5", 600),
97
+ ("claude-fable-5", 600),
98
+ ("claude-sonnet-5", 600),
99
+ ("claude-opus-4-8", 600),
100
+ ("claude-sonnet-4-6", 600),
101
+ ("claude-sonnet-4-5", 100),
102
+ ("claude-haiku-4-5", 100),
103
+ ("claude-opus-4-1", 100),
104
+ ],
105
+ )
106
+ def test_claude_4x_5x(self, model_name: str, expected_pages: int) -> None:
107
+ caps = LLMeta(model_name).capabilities
108
+ surface = caps.tool_result_surfaces[LLMApiSurface.CLAUDE_MESSAGES]
109
+
110
+ assert surface.modalities == ("image", "document")
111
+ assert surface.supports("image") is True
112
+ assert surface.supports("document") is True
113
+ assert surface.supports("audio") is False
114
+ assert surface.supports("video") is False
115
+ assert surface.mime_types is not None
116
+ assert surface.mime_types["document"] == ["application/pdf"]
117
+ assert surface.mime_types["image"] == ["image/jpeg", "image/png", "image/gif", "image/webp"]
118
+
119
+ # W3 文档上限 / W3 document limits
120
+ # 官方只公布请求级 128 MB,没有单文件上限 → max_pdf_size_mb 保持 None
121
+ # Only a request-level 128 MB cap is documented, so the per-file size stays None
122
+ assert caps.supports_pdf is True
123
+ assert caps.max_pdf_size_mb is None
124
+ assert caps.max_pdf_pages == expected_pages
125
+ assert caps.supported_document_mime_type == ["application/pdf"]
126
+ assert caps.max_inline_media_bytes == 128_000_000
127
+ assert caps.max_inline_file_bytes is None # 官方无文件合计上限 / no combined file cap
128
+
129
+ def test_claude_3x_is_undeclared(self) -> None:
130
+ """Claude 3.x 未登记工具结果 surface 数据 / Claude 3.x leaves surfaces undeclared"""
131
+ caps = LLMeta("claude-3-7-sonnet").capabilities
132
+ assert LLMApiSurface.CLAUDE_MESSAGES not in caps.tool_result_surfaces
133
+ assert caps.max_pdf_size_mb is None
134
+ assert caps.max_pdf_pages is None
135
+
136
+
137
+ class TestOpenAIToolResults:
138
+ """OpenAI:Chat Completions text-only;Responses 支持 image / document"""
139
+
140
+ @pytest.mark.parametrize("model_name", ["gpt-4o", "gpt-5.4-mini", "gpt-6-astra", "o3"])
141
+ def test_chat_completions_unsupported_responses_supported(self, model_name: str) -> None:
142
+ caps = LLMeta(model_name).capabilities
143
+ chat = caps.tool_result_surfaces[LLMApiSurface.OPENAI_CHAT_COMPLETIONS]
144
+ responses = caps.tool_result_surfaces[LLMApiSurface.OPENAI_RESPONSES]
145
+
146
+ assert chat.modalities == ()
147
+ assert chat.supports("image") is False
148
+ assert responses.modalities == ("image", "document")
149
+ assert responses.supports("image") is True
150
+ assert responses.supports("document") is True
151
+ assert responses.mime_types is not None
152
+ assert responses.mime_types["document"] == ["application/pdf"]
153
+
154
+ assert caps.supports_pdf is True
155
+ assert caps.max_pdf_size_mb == 50.0
156
+ assert caps.max_pdf_pages is None # 官方未公布页数上限 / no documented page cap
157
+ assert caps.supported_document_mime_type == ["application/pdf"]
158
+ assert caps.max_inline_media_bytes == 512_000_000
159
+ assert caps.max_inline_file_bytes == 50_000_000 # 所有文件合计 <50 MB / combined files
160
+
161
+ @pytest.mark.parametrize("model_name", ["o3-mini", "gpt-4o-audio-preview"])
162
+ def test_non_vision_chat_only(self, model_name: str) -> None:
163
+ caps = LLMeta(model_name).capabilities
164
+ assert caps.tool_result_surfaces[LLMApiSurface.OPENAI_CHAT_COMPLETIONS].modalities == ()
165
+ assert LLMApiSurface.OPENAI_RESPONSES not in caps.tool_result_surfaces
166
+
167
+ def test_gpt_3_5_chat_only(self) -> None:
168
+ caps = LLMeta("gpt-3.5-turbo").capabilities
169
+ assert caps.tool_result_surfaces[LLMApiSurface.OPENAI_CHAT_COMPLETIONS].modalities == ()
170
+ assert caps.max_pdf_size_mb is None
171
+
172
+
173
+ class TestGeminiToolResults:
174
+ """Gemini:仅 3 系支持多模态 function response;2.x 必须显式标不支持"""
175
+
176
+ @pytest.mark.parametrize(
177
+ "model_name",
178
+ ["gemini-3.8-flash", "gemini-3.1-pro-preview", "gemini-3-flash-preview", "gemini-3-pro"],
179
+ )
180
+ def test_gemini_3_supports_image(self, model_name: str) -> None:
181
+ caps = LLMeta(model_name).capabilities
182
+ surface = caps.tool_result_surfaces[LLMApiSurface.GEMINI]
183
+ assert surface.modalities == ("image",)
184
+ assert surface.supports("image") is True
185
+ assert surface.supports("document") is False # 官方 FunctionResult 仅登记 image / only image is documented
186
+
187
+ # 官方未公布单文件 PDF 上限(inline payload 100 MB / PDF 50 MB 均为请求级)
188
+ # No per-file PDF cap is documented; the inline payload caps are request-level
189
+ assert caps.max_pdf_size_mb is None
190
+ assert caps.max_pdf_pages == 1000
191
+ assert caps.supported_document_mime_type == ["application/pdf"]
192
+ assert caps.max_inline_media_bytes == 100_000_000
193
+ assert caps.max_inline_file_bytes == 50_000_000
194
+
195
+ @pytest.mark.parametrize(
196
+ "model_name",
197
+ ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.0-flash"],
198
+ )
199
+ def test_gemini_2x_explicitly_unsupported(self, model_name: str) -> None:
200
+ """2.x 明确标为不支持(空元组),而非缺键 / 2.x is explicitly unsupported, not undeclared"""
201
+ caps = LLMeta(model_name).capabilities
202
+ surface = caps.tool_result_surfaces[LLMApiSurface.GEMINI]
203
+ assert surface.modalities == ()
204
+ assert surface.supports("image") is False
205
+
206
+ @pytest.mark.parametrize("model_name", ["gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite"])
207
+ def test_gemini_2_5_pdf_limits(self, model_name: str) -> None:
208
+ """用户消息的 PDF 能力不受工具结果限制影响 / User-message PDF capability is unaffected"""
209
+ caps = LLMeta(model_name).capabilities
210
+ assert caps.supports_pdf is True
211
+ assert caps.max_pdf_pages == 1000
212
+ assert caps.max_pdf_size_mb is None
213
+ assert caps.max_inline_file_bytes == 50_000_000
214
+
215
+ def test_image_only_model_has_no_surface(self) -> None:
216
+ """无 function calling 的模型不登记 surface / Models without function calling declare no surface"""
217
+ caps = LLMeta("gemini-2.5-flash-image").capabilities
218
+ assert LLMApiSurface.GEMINI not in caps.tool_result_surfaces
219
+
220
+
221
+ class TestDeepSeekToolResults:
222
+ """DeepSeek:Chat Completions text-only;Responses 图片可用、PDF 静默丢失"""
223
+
224
+ @pytest.mark.parametrize(
225
+ "model_name",
226
+ ["deepseek-flash", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"],
227
+ )
228
+ def test_chat_completions_text_only(self, model_name: str) -> None:
229
+ caps = LLMeta(model_name).capabilities
230
+ surface = caps.tool_result_surfaces[LLMApiSurface.DEEPSEEK]
231
+ assert surface.modalities == ()
232
+ assert surface.supports("image") is False
233
+
234
+ # 实测 2026-10-29:PDF input_file 被接受但内容不投递 / PDF is silently dropped
235
+ assert caps.supports_pdf is False
236
+ assert caps.supported_document_mime_type == []
237
+ assert caps.max_pdf_size_mb is None
238
+ assert caps.max_pdf_pages is None
239
+ assert caps.max_inline_file_bytes is None
240
+
241
+ @pytest.mark.parametrize(
242
+ "model_name",
243
+ ["deepseek-flash", "deepseek-chat", "deepseek-reasoner"],
244
+ )
245
+ def test_responses_supports_image_not_document(self, model_name: str) -> None:
246
+ caps = LLMeta(model_name).capabilities
247
+ surface = caps.tool_result_surfaces[LLMApiSurface.DEEPSEEK_RESPONSES]
248
+ assert surface.modalities == ("image",)
249
+ assert surface.supports("image") is True
250
+ assert surface.supports("document") is False
251
+ assert surface.max_inline_bytes == 32 * 1024 * 1024
252
+ assert surface.mime_types is not None
253
+ assert surface.mime_types["image"] == ["image/jpeg", "image/png", "image/gif", "image/webp"]
254
+ assert caps.max_inline_media_bytes == 48 * 1024 * 1024
255
+
256
+ def test_responses_text_only_model(self) -> None:
257
+ caps = LLMeta("deepseek-v4-pro").capabilities
258
+ surface = caps.tool_result_surfaces[LLMApiSurface.DEEPSEEK_RESPONSES]
259
+ assert surface.modalities == ()
260
+ assert caps.max_inline_media_bytes is None
261
+
262
+
263
+ class TestExplicitlyUnsupportedSurfaces:
264
+ """官方明确不支持:显式空元组 / Officially unsupported: explicit empty tuple"""
265
+
266
+ @pytest.mark.parametrize(
267
+ ("model_name", "surface"),
268
+ [
269
+ ("qwen3-vl-plus", LLMApiSurface.DASHSCOPE),
270
+ ("qwen-plus", LLMApiSurface.DASHSCOPE),
271
+ ("glm-4.6v", LLMApiSurface.GLM),
272
+ ("glm-5v-turbo", LLMApiSurface.GLM),
273
+ ],
274
+ )
275
+ def test_dashscope_and_glm_text_only(self, model_name: str, surface: LLMApiSurface) -> None:
276
+ """官方 API 参考将 tool 消息 content 定义为字符串 / Official API defines tool content as string"""
277
+ caps = LLMeta(model_name).capabilities
278
+ assert caps.tool_result_surfaces[surface].modalities == ()
279
+ assert caps.tool_result_surfaces[surface].supports("image") is False
@@ -968,7 +968,7 @@ wheels = [
968
968
 
969
969
  [[package]]
970
970
  name = "whosellm"
971
- version = "0.3.1"
971
+ version = "0.4.0"
972
972
  source = { editable = "." }
973
973
  dependencies = [
974
974
  { name = "parse" },
@@ -7,19 +7,31 @@
7
7
  LLMeta - 统一的大语言模型版本和能力管理库 / A unified LLM model version and capability management library
8
8
  """
9
9
 
10
- __version__ = "0.3.1"
10
+ __version__ = "0.4.0"
11
11
 
12
- from whosellm.capabilities import UNLIMITED, MediaCountLimit, ModelCapabilities
12
+ from whosellm.capabilities import (
13
+ UNLIMITED,
14
+ FileApiCapability,
15
+ LLMApiSurface,
16
+ MediaCountLimit,
17
+ ModelCapabilities,
18
+ ToolResultModality,
19
+ ToolResultSurfaceCapability,
20
+ )
13
21
  from whosellm.model_version import LLMeta
14
22
  from whosellm.models.base import ModelFamily
15
23
  from whosellm.provider import Provider
16
24
 
17
25
  __all__ = [
18
26
  "UNLIMITED",
27
+ "FileApiCapability",
28
+ "LLMApiSurface",
19
29
  "LLMeta",
20
30
  "MediaCountLimit",
21
31
  "ModelCapabilities",
22
32
  "ModelFamily",
23
33
  "Provider",
34
+ "ToolResultModality",
35
+ "ToolResultSurfaceCapability",
24
36
  "__version__",
25
37
  ]