sdpy-kit 0.6.6__tar.gz → 0.6.7__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 (54) hide show
  1. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/PKG-INFO +48 -14
  2. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/README.md +47 -13
  3. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/base_service.py +17 -1
  5. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/chat_service.py +122 -8
  6. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/failover.py +12 -1
  7. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/pool.py +2 -0
  8. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/profiles.py +80 -2
  9. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/runner.py +61 -14
  10. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/.gitignore +0 -0
  11. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/__init__.py +0 -0
  12. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/embedding_service.py +0 -0
  13. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/exceptions.py +0 -0
  14. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/image_service.py +0 -0
  15. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/model_builder.py +0 -0
  16. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/model_client.py +0 -0
  17. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/rerank_service.py +0 -0
  18. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/ai/types.py +0 -0
  19. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/common_kit.py +0 -0
  20. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/config/__init__.py +0 -0
  21. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/config/kit_config.py +0 -0
  22. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/config/log_config.py +0 -0
  23. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/crawl/__init__.py +0 -0
  24. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/crawl/article_io.py +0 -0
  25. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/crawl/article_models.py +0 -0
  26. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/crawl/deepseek_kit.py +0 -0
  27. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/crawl/web_article_kit.py +0 -0
  28. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/crawl/wx_article_kit.py +0 -0
  29. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/__init__.py +0 -0
  30. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/audit.py +0 -0
  31. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/base_db_kit.py +0 -0
  32. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/crud.py +0 -0
  33. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/engine.py +0 -0
  34. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/exceptions.py +0 -0
  35. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/models/__init__.py +0 -0
  36. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/models/base.py +0 -0
  37. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/mysql_kit.py +0 -0
  38. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/pg_kit.py +0 -0
  39. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/query.py +0 -0
  40. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/session.py +0 -0
  41. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/sqlite_kit.py +0 -0
  42. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/db/transaction.py +0 -0
  43. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/docs/__init__.py +0 -0
  44. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/docs/anydoc_kit.py +0 -0
  45. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/docs/docx_kit.py +0 -0
  46. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/docs/marker_kit.py +0 -0
  47. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/docs/md2doc_kit.py +0 -0
  48. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/docs/mineru_kit.py +0 -0
  49. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/docs/rc_docparser_kit.py +0 -0
  50. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/file_kit.py +0 -0
  51. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/laya_kit.py +0 -0
  52. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/license_kit.py +0 -0
  53. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/kit/redis_kit.py +0 -0
  54. {sdpy_kit-0.6.6 → sdpy_kit-0.6.7}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.6.6
3
+ Version: 0.6.7
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -783,9 +783,9 @@ shutdown() # 应用退出前
783
783
 
784
784
  | 分组 | 方法 | 说明 |
785
785
  |------|------|------|
786
- | chat | `chat_complete(prompt, *, max_tokens=None) -> str` | 单轮对话(仅 user 消息) |
787
- | chat | `chat_ask(messages, *, max_tokens=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
788
- | chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
786
+ | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None) -> str` | 单轮对话(仅 user 消息) |
787
+ | chat | `chat_ask(messages, *, max_tokens=None, thinking=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
788
+ | chat | `chat_stream(messages, *, max_tokens=None, thinking=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` / `thinking_applied` |
789
789
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
790
790
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
791
791
  | rerank | `rerank(query, documents, top_n=None) -> list[tuple[int, float]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
@@ -804,8 +804,8 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
804
804
 
805
805
  #### 流式收尾统计(思考型模型必备)
806
806
 
807
- 迭代器在流结束后可读四个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
808
- `truncated`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
807
+ 迭代器在流结束后可读五个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
808
+ `truncated` / `thinking_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
809
809
  token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
810
810
 
811
811
  ```python
@@ -814,7 +814,7 @@ with AI.chat_stream(messages, max_tokens=450) as stream:
814
814
  if not body and stream.truncated and stream.reasoning_chars:
815
815
  raise ValueError(
816
816
  f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
817
- "请调大 max_tokens 或改用关闭思考的档案"
817
+ "请调大 max_tokens 或对该调用传 thinking=False"
818
818
  )
819
819
  ```
820
820
 
@@ -826,6 +826,29 @@ if not body and stream.truncated and stream.reasoning_chars:
826
826
  - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
827
827
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
828
828
 
829
+ #### 逐调用思考开关(0.6.7)
830
+
831
+ 同一档案下按调用点切换思考(入库抽取关、章节生成开),不必复制档案:
832
+
833
+ ```python
834
+ AI.chat_ask(messages, thinking=False) # 关闭思考
835
+ AI.chat_ask(messages, thinking=True) # 开启思考
836
+ AI.chat_ask(messages) # None:不干预(按档案 thinking.default;未配置则请求体逐字节不变)
837
+ ```
838
+
839
+ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明翻译(见「ai_models.json 可选字段」)。
840
+ 三条局限:
841
+
842
+ - **未声明即忽略**:档案没有 `thinking` 声明时,逐调用 `thinking` 被忽略并打 `WARNING`(不报错、不触发冷却),调用照常成功;
843
+ - **跨候选降级**:候选链切到未声明的备胎时,本次调用退回厂商默认(通常是开),只在日志与 `thinking_applied` 中体现;
844
+ - **不提供档位**:只有开/关;`low` / `medium` / `high` 这类分级本轮不支持(可用 `on` 直接给厂商取值)。
845
+
846
+ `thinking_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求干预 / `True` 已按声明注入 / `False` 要求了但被忽略。
847
+
848
+ > 这是对 0.6.5「不新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」结论的**有意修订**:
849
+ > 当时的否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
850
+ > 字段只发给声明了它的候选,未声明者不注入。思考**档位**(`low/medium/high`)仍然不做。
851
+
829
852
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
830
853
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
831
854
 
@@ -853,7 +876,7 @@ await service.close()
853
876
 
854
877
  | 类 | 方法 |
855
878
  |----|------|
856
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
879
+ | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, thinking=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., thinking=None, stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计);`thinking` 语义与门面一致 |
857
880
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
858
881
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
859
882
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1386,14 +1409,25 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1386
1409
  | 档案级 | `timeout` | 请求超时秒数(默认 60) |
1387
1410
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1388
1411
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1389
- | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。改文件后需 `reload_config(reload_file=True)` 生效 |
1412
+ | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。逐调用 `thinking` 与之同键时**以逐调用为准**并打 `WARNING`。改文件后需 `reload_config(reload_file=True)` 生效 |
1413
+ | 档案级 | `thinking` | 思考开关的注入声明(0.6.7):`{"arg": "thinking", "off": {"type": "disabled"}, "on": {"type": "enabled"}, "default": false}`。`arg` 是请求体顶层键名(不得占用 `model` / `messages` / `stream` / `max_tokens` / `temperature` 等保留键,配置期与运行期双重校验);`off` / `on` 可为**任意 JSON 值**(双方都是 dict 时浅合并,否则整键替换);`on` 缺省表示不能显式开启;`default` 为档案默认三态(`null` 不干预 / `false` 默认关 / `true` 默认开)。**未声明 = 不支持按调用切换**:逐调用 `thinking` 被忽略并告警(不报错、不影响候选链冷却)。kit 只做翻译,不含任何厂商名 |
1390
1414
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1391
1415
 
1392
- > **`extra_body` 的用法边界(档案级开关的坑)**:`AI.chat_* / chat_stream` 固定走**当前候选链头**,
1393
- > 档案级 `extra_body`(如关闭思考)无法按调用点切换。需要「同一模型,长文开思考、短文关思考」
1394
- > 并存时,在 `ai_models.json` 里**复制一份档案**(同 `model`、多加 `extra_body`),再用
1395
- > `ModelBuilder.build_chat("<档案名>")` 取独立异步服务;门面上只能 `AI.use("chat", "<档案名>")`
1396
- > 临时切换(进程级、非并发安全)。
1416
+ `thinking` 声明两种实测形态(按平台各自写入档案):
1417
+
1418
+ ```json
1419
+ "thinking": {"arg": "thinking", "off": {"type": "disabled"}}
1420
+ "thinking": {"arg": "chat_template_kwargs", "off": {"enable_thinking": false}}
1421
+ ```
1422
+
1423
+ - **未知字段的厂商反应不一致**:有的返回 400、有的**静默忽略**(仍照常思考)。所以 kit 只按声明下发,绝不猜厂商默认值;
1424
+ - **强制思考模型**(如只接受 `low/high/max` 的平台)不写 `thinking` 声明,逐调用开关会被忽略并告警;
1425
+ - 声明写错(`arg` 拼错、值形态不符)由厂商判定,可能 400 → 冷却;语义正确性由使用方的模型能力清单保证。
1426
+
1427
+ > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1428
+ > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1429
+ > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1430
+ > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1397
1431
 
1398
1432
  ### 候选链切换语义(两类,勿混淆)
1399
1433
 
@@ -707,9 +707,9 @@ shutdown() # 应用退出前
707
707
 
708
708
  | 分组 | 方法 | 说明 |
709
709
  |------|------|------|
710
- | chat | `chat_complete(prompt, *, max_tokens=None) -> str` | 单轮对话(仅 user 消息) |
711
- | chat | `chat_ask(messages, *, max_tokens=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
712
- | chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
710
+ | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None) -> str` | 单轮对话(仅 user 消息) |
711
+ | chat | `chat_ask(messages, *, max_tokens=None, thinking=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
712
+ | chat | `chat_stream(messages, *, max_tokens=None, thinking=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` / `thinking_applied` |
713
713
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
714
714
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
715
715
  | rerank | `rerank(query, documents, top_n=None) -> list[tuple[int, float]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
@@ -728,8 +728,8 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
728
728
 
729
729
  #### 流式收尾统计(思考型模型必备)
730
730
 
731
- 迭代器在流结束后可读四个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
732
- `truncated`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
731
+ 迭代器在流结束后可读五个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
732
+ `truncated` / `thinking_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
733
733
  token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
734
734
 
735
735
  ```python
@@ -738,7 +738,7 @@ with AI.chat_stream(messages, max_tokens=450) as stream:
738
738
  if not body and stream.truncated and stream.reasoning_chars:
739
739
  raise ValueError(
740
740
  f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
741
- "请调大 max_tokens 或改用关闭思考的档案"
741
+ "请调大 max_tokens 或对该调用传 thinking=False"
742
742
  )
743
743
  ```
744
744
 
@@ -750,6 +750,29 @@ if not body and stream.truncated and stream.reasoning_chars:
750
750
  - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
751
751
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
752
752
 
753
+ #### 逐调用思考开关(0.6.7)
754
+
755
+ 同一档案下按调用点切换思考(入库抽取关、章节生成开),不必复制档案:
756
+
757
+ ```python
758
+ AI.chat_ask(messages, thinking=False) # 关闭思考
759
+ AI.chat_ask(messages, thinking=True) # 开启思考
760
+ AI.chat_ask(messages) # None:不干预(按档案 thinking.default;未配置则请求体逐字节不变)
761
+ ```
762
+
763
+ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明翻译(见「ai_models.json 可选字段」)。
764
+ 三条局限:
765
+
766
+ - **未声明即忽略**:档案没有 `thinking` 声明时,逐调用 `thinking` 被忽略并打 `WARNING`(不报错、不触发冷却),调用照常成功;
767
+ - **跨候选降级**:候选链切到未声明的备胎时,本次调用退回厂商默认(通常是开),只在日志与 `thinking_applied` 中体现;
768
+ - **不提供档位**:只有开/关;`low` / `medium` / `high` 这类分级本轮不支持(可用 `on` 直接给厂商取值)。
769
+
770
+ `thinking_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求干预 / `True` 已按声明注入 / `False` 要求了但被忽略。
771
+
772
+ > 这是对 0.6.5「不新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」结论的**有意修订**:
773
+ > 当时的否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
774
+ > 字段只发给声明了它的候选,未声明者不注入。思考**档位**(`low/medium/high`)仍然不做。
775
+
753
776
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
754
777
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
755
778
 
@@ -777,7 +800,7 @@ await service.close()
777
800
 
778
801
  | 类 | 方法 |
779
802
  |----|------|
780
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
803
+ | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, thinking=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., thinking=None, stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计);`thinking` 语义与门面一致 |
781
804
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
782
805
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
783
806
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1310,14 +1333,25 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1310
1333
  | 档案级 | `timeout` | 请求超时秒数(默认 60) |
1311
1334
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1312
1335
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1313
- | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。改文件后需 `reload_config(reload_file=True)` 生效 |
1336
+ | 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens`)无法用它实现。逐调用 `thinking` 与之同键时**以逐调用为准**并打 `WARNING`。改文件后需 `reload_config(reload_file=True)` 生效 |
1337
+ | 档案级 | `thinking` | 思考开关的注入声明(0.6.7):`{"arg": "thinking", "off": {"type": "disabled"}, "on": {"type": "enabled"}, "default": false}`。`arg` 是请求体顶层键名(不得占用 `model` / `messages` / `stream` / `max_tokens` / `temperature` 等保留键,配置期与运行期双重校验);`off` / `on` 可为**任意 JSON 值**(双方都是 dict 时浅合并,否则整键替换);`on` 缺省表示不能显式开启;`default` 为档案默认三态(`null` 不干预 / `false` 默认关 / `true` 默认开)。**未声明 = 不支持按调用切换**:逐调用 `thinking` 被忽略并告警(不报错、不影响候选链冷却)。kit 只做翻译,不含任何厂商名 |
1314
1338
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1315
1339
 
1316
- > **`extra_body` 的用法边界(档案级开关的坑)**:`AI.chat_* / chat_stream` 固定走**当前候选链头**,
1317
- > 档案级 `extra_body`(如关闭思考)无法按调用点切换。需要「同一模型,长文开思考、短文关思考」
1318
- > 并存时,在 `ai_models.json` 里**复制一份档案**(同 `model`、多加 `extra_body`),再用
1319
- > `ModelBuilder.build_chat("<档案名>")` 取独立异步服务;门面上只能 `AI.use("chat", "<档案名>")`
1320
- > 临时切换(进程级、非并发安全)。
1340
+ `thinking` 声明两种实测形态(按平台各自写入档案):
1341
+
1342
+ ```json
1343
+ "thinking": {"arg": "thinking", "off": {"type": "disabled"}}
1344
+ "thinking": {"arg": "chat_template_kwargs", "off": {"enable_thinking": false}}
1345
+ ```
1346
+
1347
+ - **未知字段的厂商反应不一致**:有的返回 400、有的**静默忽略**(仍照常思考)。所以 kit 只按声明下发,绝不猜厂商默认值;
1348
+ - **强制思考模型**(如只接受 `low/high/max` 的平台)不写 `thinking` 声明,逐调用开关会被忽略并告警;
1349
+ - 声明写错(`arg` 拼错、值形态不符)由厂商判定,可能 400 → 冷却;语义正确性由使用方的模型能力清单保证。
1350
+
1351
+ > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1352
+ > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1353
+ > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1354
+ > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1321
1355
 
1322
1356
  ### 候选链切换语义(两类,勿混淆)
1323
1357
 
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.6.6"
26
+ __version__ = "0.6.7"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -115,6 +115,9 @@ class BaseAIService[RequestT, ResponseT](ABC):
115
115
  同名键以服务构造的值为准——``model`` / ``messages`` / ``stream`` /
116
116
  ``input`` / ``prompt`` 等保留键**不可被档案覆盖**,故无需白名单;
117
117
  未配置时原样返回(请求体逐字节不变)。
118
+
119
+ 优先级:本方法之后还会执行 :meth:`_apply_request_extras`(逐调用
120
+ 增强),故同一键若被逐调用显式覆盖,以逐调用为准(更具体者优先)。
118
121
  """
119
122
  extra = self._profile.extra_body
120
123
  if not extra:
@@ -122,6 +125,17 @@ class BaseAIService[RequestT, ResponseT](ABC):
122
125
  merged: dict[str, object] = {**extra, **payload}
123
126
  return cast(JSONObject, merged)
124
127
 
128
+ def _apply_request_extras(
129
+ self, payload: JSONObject, request: RequestT
130
+ ) -> JSONObject:
131
+ """逐调用请求增强钩子(默认无操作:请求体逐字节不变)。
132
+
133
+ 在 :meth:`_apply_extra_body` 之后执行,用于按本次调用参数改写请求体
134
+ (如 ``ChatService`` 的思考开关)。子类按需覆写;覆写方须自行保证
135
+ 不覆盖 ``_build_payload`` 产出的保留键。
136
+ """
137
+ return payload
138
+
125
139
  @abstractmethod
126
140
  def _parse_response(self, data: JSONObject) -> ResponseT:
127
141
  """将 JSON 响应体解析为业务结果。"""
@@ -137,7 +151,9 @@ class BaseAIService[RequestT, ResponseT](ABC):
137
151
  ``post_json`` 的重试预算检查;None 表示不限制
138
152
  """
139
153
  desc = self._log_desc()
140
- payload = self._apply_extra_body(self._build_payload(request))
154
+ payload = self._apply_request_extras(
155
+ self._apply_extra_body(self._build_payload(request)), request
156
+ )
141
157
  params = _truncate(
142
158
  json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
143
159
  )
@@ -18,10 +18,11 @@ Author: Clark Chang
18
18
  """
19
19
 
20
20
  import json
21
+ import threading
21
22
  import time
22
23
  from collections.abc import AsyncIterator
23
24
  from dataclasses import dataclass
24
- from typing import ClassVar, Final, cast, override
25
+ from typing import Any, ClassVar, Final, cast, override
25
26
 
26
27
  from kit.ai.base_service import (
27
28
  _LOG_PAYLOAD_LIMIT,
@@ -31,6 +32,7 @@ from kit.ai.base_service import (
31
32
  safe_get,
32
33
  )
33
34
  from kit.ai.exceptions import AIError, AIResponseError
35
+ from kit.ai.profiles import _RESERVED_PAYLOAD_KEYS, ModelProfile
34
36
  from kit.ai.types import JSONObject
35
37
  from kit.config.log_config import get_default_logger
36
38
 
@@ -47,6 +49,46 @@ _REASONING_KEYS: Final[tuple[str, ...]] = ("reasoning_content", "reasoning")
47
49
  # 截断判定:OpenAI 系给 length,部分兼容端点给 max_tokens
48
50
  _TRUNCATED_REASONS: Final[frozenset[str]] = frozenset({"length", "max_tokens"})
49
51
 
52
+ # 「无注入值」哨兵:区分「不注入」与「注入值为 None」
53
+ _UNSET: Final[object] = object()
54
+
55
+ # 思考开关告警去重:同一档案同一原因进程内只报一次(批处理会高频调用)
56
+ _WARNED: set[tuple[str, str]] = set()
57
+ _WARNED_LOCK: Final[threading.Lock] = threading.Lock()
58
+
59
+
60
+ def _warn_once(profile_name: str, reason: str) -> None:
61
+ """线程安全的告警去重:``(档案名, 原因)`` 只报一次。"""
62
+ key = (profile_name, reason)
63
+ with _WARNED_LOCK:
64
+ if key in _WARNED:
65
+ return
66
+ _WARNED.add(key)
67
+ logger.warning(f"思考开关:{reason}(档案 {profile_name})")
68
+
69
+
70
+ def _resolve_thinking(
71
+ profile: ModelProfile, thinking: bool | None
72
+ ) -> tuple[object, bool | None, str | None]:
73
+ """解析本次调用的思考开关。
74
+
75
+ 逐调用 ``thinking`` 优先,未传时回落档案 ``thinking.default``(三态)。
76
+
77
+ Returns:
78
+ ``(注入值 | _UNSET, 是否生效, 告警原因)``。是否生效为 None 表示
79
+ 本次未要求干预(请求体不变);False 表示要求了但档案不支持
80
+ (不注入、不报错、不影响候选链冷却)。
81
+ """
82
+ spec = profile.thinking
83
+ mode = thinking if thinking is not None else (spec.default if spec else None)
84
+ if mode is None:
85
+ return _UNSET, None, None
86
+ if spec is None:
87
+ return _UNSET, False, "档案未声明思考开关,本次请求被忽略"
88
+ if mode and spec.on is None:
89
+ return _UNSET, False, "档案未声明开启取值,本次请求被忽略"
90
+ return (spec.off if mode is False else spec.on), True, None
91
+
50
92
 
51
93
  @dataclass
52
94
  class StreamStats:
@@ -64,11 +106,14 @@ class StreamStats:
64
106
  content_chars: 已产出正文字符数(等于调用方收到的字符数)
65
107
  finish_reason: 结束原因(stop / length / content_filter / …);
66
108
  provider 未给出时为 None
109
+ thinking_applied: 思考开关是否生效——None 本次未要求干预 /
110
+ True 已按档案声明注入 / False 要求了但档案不支持(已忽略)
67
111
  """
68
112
 
69
113
  reasoning_chars: int = 0
70
114
  content_chars: int = 0
71
115
  finish_reason: str | None = None
116
+ thinking_applied: bool | None = None
72
117
 
73
118
  @property
74
119
  def truncated(self) -> bool:
@@ -80,6 +125,7 @@ class StreamStats:
80
125
  self.reasoning_chars = 0
81
126
  self.content_chars = 0
82
127
  self.finish_reason = None
128
+ self.thinking_applied = None
83
129
 
84
130
 
85
131
  @dataclass(frozen=True)
@@ -90,6 +136,8 @@ class ChatRequest:
90
136
  temperature: float | None = None
91
137
  max_tokens: int | None = None
92
138
  stream: bool = False
139
+ # 思考开关:None 不干预(请求体逐字节不变)/ True 开 / False 关
140
+ thinking: bool | None = None
93
141
 
94
142
 
95
143
  class ChatService(BaseAIService[ChatRequest, str]):
@@ -116,6 +164,47 @@ class ChatService(BaseAIService[ChatRequest, str]):
116
164
  payload["max_tokens"] = max_tokens
117
165
  return cast(JSONObject, payload)
118
166
 
167
+ @override
168
+ def _apply_request_extras(
169
+ self, payload: JSONObject, request: ChatRequest
170
+ ) -> JSONObject:
171
+ """按档案 ``thinking`` 声明注入逐调用思考开关。
172
+
173
+ 优先级:逐调用 > 档案 ``extra_body``(同键且值不同时告警)。
174
+ 未声明 / 不可显式开启 → 不注入 + 告警(不报错、不触发冷却)。
175
+ ``_build_payload`` 产出的保留键在运行期兜底拒绝覆盖。
176
+ """
177
+ value, applied, reason = _resolve_thinking(self._profile, request.thinking)
178
+ if applied is False and reason is not None:
179
+ _warn_once(self._profile.name, reason)
180
+ if value is _UNSET:
181
+ return payload
182
+ spec = self._profile.thinking
183
+ if spec is None: # 类型收窄:applied 为 True 时 spec 必非 None
184
+ return payload
185
+ if spec.arg in _RESERVED_PAYLOAD_KEYS or spec.arg in self._build_payload(
186
+ request
187
+ ):
188
+ _warn_once(
189
+ self._profile.name,
190
+ f"思考开关键 {spec.arg!r} 与请求体保留键冲突,已忽略",
191
+ )
192
+ return payload
193
+ current = payload.get(spec.arg)
194
+ merged: Any = (
195
+ {**current, **value}
196
+ if isinstance(current, dict) and isinstance(value, dict)
197
+ else value
198
+ )
199
+ extra = self._profile.extra_body or {}
200
+ if spec.arg in extra and extra[spec.arg] != merged:
201
+ _warn_once(
202
+ self._profile.name,
203
+ f"已覆盖档案 extra_body 的同名字段 {spec.arg!r}",
204
+ )
205
+ payload[spec.arg] = merged
206
+ return payload
207
+
119
208
  @override
120
209
  def _parse_response(self, data: JSONObject) -> str:
121
210
  choice = safe_get(data, "choices", 0)
@@ -137,14 +226,14 @@ class ChatService(BaseAIService[ChatRequest, str]):
137
226
  raise AIResponseError(
138
227
  f"输出预算被思考耗尽:finish_reason={finish},"
139
228
  f"推理 {reasoning} 字符、正文为空"
140
- "(请调大 max_tokens 或在档案 extra_body 中关闭思考)"
229
+ "(请调大 max_tokens,或对该调用传 thinking=False 关闭思考)"
141
230
  )
142
231
  raise AIResponseError(f"content 类型异常: {type(content).__name__}")
143
232
  if finish in _TRUNCATED_REASONS and (reasoning or not content):
144
233
  logger.warning(
145
234
  f"{self._log_desc()} 输出被长度截断(finish_reason={finish}):"
146
235
  f"推理 {reasoning} 字符 / 正文 {len(content)} 字符"
147
- "(可调大 max_tokens 或在档案 extra_body 中关闭思考)"
236
+ "(可调大 max_tokens,或对该调用传 thinking=False 关闭思考)"
148
237
  )
149
238
  return content
150
239
 
@@ -154,15 +243,24 @@ class ChatService(BaseAIService[ChatRequest, str]):
154
243
  *,
155
244
  temperature: float | None = None,
156
245
  max_tokens: int | None = None,
246
+ thinking: bool | None = None,
157
247
  deadline: float | None = None,
158
248
  ) -> str:
159
249
  """多轮对话,返回模型回答文本。
160
250
 
161
251
  Args:
252
+ thinking: 思考开关;None 不干预(按档案 ``thinking.default``,
253
+ 仍未配置则请求体不变)/ True 开 / False 关。档案无声明时
254
+ 忽略并告警(不报错、不触发冷却)
162
255
  deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
163
256
  5xx 重试预算;None 表示不限制
164
257
  """
165
- request = ChatRequest(messages, temperature=temperature, max_tokens=max_tokens)
258
+ request = ChatRequest(
259
+ messages,
260
+ temperature=temperature,
261
+ max_tokens=max_tokens,
262
+ thinking=thinking,
263
+ )
166
264
  return await self._call(request, deadline=deadline)
167
265
 
168
266
  async def complete(
@@ -171,11 +269,16 @@ class ChatService(BaseAIService[ChatRequest, str]):
171
269
  *,
172
270
  temperature: float | None = None,
173
271
  max_tokens: int | None = None,
272
+ thinking: bool | None = None,
174
273
  deadline: float | None = None,
175
274
  ) -> str:
176
275
  messages: list[Message] = [{"role": "user", "content": prompt}]
177
276
  return await self.ask(
178
- messages, temperature=temperature, max_tokens=max_tokens, deadline=deadline
277
+ messages,
278
+ temperature=temperature,
279
+ max_tokens=max_tokens,
280
+ thinking=thinking,
281
+ deadline=deadline,
179
282
  )
180
283
 
181
284
  async def stream(
@@ -184,11 +287,15 @@ class ChatService(BaseAIService[ChatRequest, str]):
184
287
  *,
185
288
  temperature: float | None = None,
186
289
  max_tokens: int | None = None,
290
+ thinking: bool | None = None,
187
291
  stats: StreamStats | None = None,
188
292
  ) -> AsyncIterator[str]:
189
293
  """流式对话,逐 token 产出正文。
190
294
 
191
295
  Args:
296
+ thinking: 思考开关(语义同 :meth:`ask`);是否真正生效记在
297
+ ``stats.thinking_applied``(None 未干预 / True 已注入 /
298
+ False 被忽略)
192
299
  stats: 收尾统计累加器(可选);None 时内部创建后丢弃——需要
193
300
  ``reasoning_chars`` / ``finish_reason`` / ``truncated`` 时
194
301
  由调用方传入一个实例
@@ -197,14 +304,21 @@ class ChatService(BaseAIService[ChatRequest, str]):
197
304
  不产出;正文为空且被截断(或发生过思考)时输出 warning。
198
305
  """
199
306
  request = ChatRequest(
200
- messages, temperature=temperature, max_tokens=max_tokens, stream=True
307
+ messages,
308
+ temperature=temperature,
309
+ max_tokens=max_tokens,
310
+ stream=True,
311
+ thinking=thinking,
312
+ )
313
+ payload = self._apply_request_extras(
314
+ self._apply_extra_body(self._build_payload(request)), request
201
315
  )
202
- payload = self._apply_extra_body(self._build_payload(request))
203
316
  desc = self._log_desc()
204
317
  params = _truncate(
205
318
  json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
206
319
  )
207
320
  stats = stats if stats is not None else StreamStats()
321
+ stats.thinking_applied = _resolve_thinking(self._profile, request.thinking)[1]
208
322
  # 惰性 Start:异步生成器体在首次 __anext__ 前不执行,不消费不打日志
209
323
  logger.info(f"********** {desc} ********** Start")
210
324
  logger.info(f"请求参数: {params}")
@@ -262,5 +376,5 @@ class ChatService(BaseAIService[ChatRequest, str]):
262
376
  f"{self._log_desc()} 输出预算疑被思考耗尽:max_tokens={max_tokens},"
263
377
  f"finish_reason={stats.finish_reason},"
264
378
  f"推理 {stats.reasoning_chars} 字符 / 正文 0 字符"
265
- "(可调大 max_tokens 或在档案 extra_body 中关闭思考)"
379
+ "(可调大 max_tokens,或对该调用传 thinking=False 关闭思考)"
266
380
  )
@@ -164,15 +164,21 @@ class FailoverChatService(_FailoverBase):
164
164
  *,
165
165
  temperature: float | None = None,
166
166
  max_tokens: int | None = None,
167
+ thinking: bool | None = None,
167
168
  deadline: float | None = None,
168
169
  ) -> str:
169
- """多轮对话,返回模型回答文本(链式故障切换)。"""
170
+ """多轮对话,返回模型回答文本(链式故障切换)。
171
+
172
+ ``thinking`` 原样下传:注入发生在 ``ChatService`` 内部,按**当前候选
173
+ 自己**的档案声明组装;无声明时该候选忽略该开关(不因此切候选)。
174
+ """
170
175
 
171
176
  async def _run(service: Service) -> str:
172
177
  return await cast(ChatService, service).ask(
173
178
  messages,
174
179
  temperature=temperature,
175
180
  max_tokens=max_tokens,
181
+ thinking=thinking,
176
182
  deadline=deadline,
177
183
  )
178
184
 
@@ -184,6 +190,7 @@ class FailoverChatService(_FailoverBase):
184
190
  *,
185
191
  temperature: float | None = None,
186
192
  max_tokens: int | None = None,
193
+ thinking: bool | None = None,
187
194
  deadline: float | None = None,
188
195
  ) -> str:
189
196
  """单轮对话,返回模型回答文本(链式故障切换)。"""
@@ -193,6 +200,7 @@ class FailoverChatService(_FailoverBase):
193
200
  prompt,
194
201
  temperature=temperature,
195
202
  max_tokens=max_tokens,
203
+ thinking=thinking,
196
204
  deadline=deadline,
197
205
  )
198
206
 
@@ -204,6 +212,7 @@ class FailoverChatService(_FailoverBase):
204
212
  *,
205
213
  temperature: float | None = None,
206
214
  max_tokens: int | None = None,
215
+ thinking: bool | None = None,
207
216
  first_frame_timeout: float | None = None,
208
217
  deadline: float | None = None,
209
218
  stats: StreamStats | None = None,
@@ -211,6 +220,7 @@ class FailoverChatService(_FailoverBase):
211
220
  """流式对话:首帧前失败自动换档案重试;首帧后失败直接抛出。
212
221
 
213
222
  Args:
223
+ thinking: 思考开关;按当前候选自己的档案声明组装,无声明则忽略
214
224
  first_frame_timeout: 单候选首帧等待上限(秒);窗口内**一帧都没有**
215
225
  (含推理帧)才按失败处理切下一候选;None 不限制
216
226
  deadline: 整链预算截止时刻;每个候选发起前检查
@@ -232,6 +242,7 @@ class FailoverChatService(_FailoverBase):
232
242
  messages,
233
243
  temperature=temperature,
234
244
  max_tokens=max_tokens,
245
+ thinking=thinking,
235
246
  stats=stats,
236
247
  ),
237
248
  )
@@ -391,6 +391,7 @@ class AIServicePool:
391
391
  current=name == current,
392
392
  last_error=entry.last_error,
393
393
  last_ok_at=entry.last_ok_at,
394
+ thinking_switchable=entry.profile.thinking is not None,
394
395
  )
395
396
  for name, entry in chain.entries.items()
396
397
  ]
@@ -406,6 +407,7 @@ class AIServicePool:
406
407
  model=config.model,
407
408
  state=ProfileState.UNCONFIGURED,
408
409
  current=False,
410
+ thinking_switchable=config.thinking is not None,
409
411
  )
410
412
  )
411
413
  return rows
@@ -34,10 +34,10 @@ import os
34
34
  from dataclasses import dataclass, replace
35
35
  from functools import lru_cache
36
36
  from pathlib import Path
37
- from typing import Any, Final
37
+ from typing import Any, Final, Self
38
38
 
39
39
  from dotenv import load_dotenv
40
- from pydantic import BaseModel, Field, ValidationError
40
+ from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
41
41
 
42
42
  from kit.ai.exceptions import AIConfigError
43
43
  from kit.config.kit_config import get_settings
@@ -56,6 +56,34 @@ _MINIMAL_MODELS_JSON: Final[str] = (
56
56
  )
57
57
 
58
58
 
59
+ # 请求体保留键:思考开关声明(档案 thinking.arg)不得占用,配置期与运行期双重校验
60
+ _RESERVED_PAYLOAD_KEYS: Final[frozenset[str]] = frozenset(
61
+ {"model", "messages", "stream", "input", "prompt", "max_tokens", "temperature"}
62
+ )
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ThinkingSwitchSpec:
67
+ """厂商思考开关的注入方式(档案声明;缺失即「不支持切换」)。
68
+
69
+ kit 不认识任何厂商字段名,只按本声明组装请求体:
70
+
71
+ Attributes:
72
+ arg: 请求体顶层键名(如 ``thinking`` / ``chat_template_kwargs`` /
73
+ ``enable_thinking``);保留键(``model`` / ``messages`` 等)禁用
74
+ off: 关闭思考时该键的取值,任意 JSON 值。与请求体中同键**都是 dict**
75
+ 时浅合并(保留其它子键),否则整键替换
76
+ on: 开启思考时的取值;``None`` 表示不能显式开启(如强制思考模型)
77
+ default: 档案默认策略——``None`` 不干预 / ``False`` 默认关 /
78
+ ``True`` 默认开。仅在逐调用未传 ``thinking`` 时生效
79
+ """
80
+
81
+ arg: str
82
+ off: Any
83
+ on: Any = None
84
+ default: bool | None = None
85
+
86
+
59
87
  @dataclass(frozen=True)
60
88
  class ModelProfile:
61
89
  """单个模型档案(运行时已解析密钥)。
@@ -78,6 +106,8 @@ class ModelProfile:
78
106
  extra_body: 档案级额外请求体字段(厂商专有参数透传,如 GLM/Qwen 的
79
107
  ``{"thinking": {"type": "disabled"}}``);与服务构造的同名键
80
108
  冲突时**以服务的值为准**(不可覆盖 model / messages / stream 等保留键)
109
+ thinking: 思考开关的注入声明(逐调用 ``thinking=`` 据此翻译成厂商字段);
110
+ None 表示该档案不支持按调用切换思考
81
111
  """
82
112
 
83
113
  name: str
@@ -93,6 +123,7 @@ class ModelProfile:
93
123
  dimension: int | None = None
94
124
  batch_size: int = 32
95
125
  extra_body: dict[str, Any] | None = None
126
+ thinking: ThinkingSwitchSpec | None = None
96
127
 
97
128
  @property
98
129
  def url(self) -> str:
@@ -122,6 +153,7 @@ class ProfileStatus:
122
153
  current: 是否为该类型当前使用项(链头)
123
154
  last_error: 最近一次失败原因(无失败为 None)
124
155
  last_ok_at: 最近一次成功/探测通过时刻(Unix 秒;从未成功为 None)
156
+ thinking_switchable: 该档案是否声明了思考开关(可逐调用关/开思考)
125
157
  """
126
158
 
127
159
  name: str
@@ -130,6 +162,49 @@ class ProfileStatus:
130
162
  current: bool = False
131
163
  last_error: str | None = None
132
164
  last_ok_at: float | None = None
165
+ thinking_switchable: bool = False
166
+
167
+
168
+ class ThinkingSwitchSpecModel(BaseModel):
169
+ """档案 ``thinking`` 字段的原始声明(结构校验,厂商无关)。
170
+
171
+ 只校验外形(``arg`` 非空且非保留键、``off`` 必须给出),语义正确性
172
+ 由应用侧预设库保证——kit 不认识任何厂商字段名。
173
+ """
174
+
175
+ arg: str = Field(min_length=1)
176
+ off: Any = None
177
+ on: Any = None
178
+ default: bool | None = None
179
+
180
+ @field_validator("arg")
181
+ @classmethod
182
+ def _validate_arg(cls, value: str) -> str:
183
+ """``arg`` 不得占用请求体保留键(否则写坏请求骨架)。"""
184
+ if value in _RESERVED_PAYLOAD_KEYS:
185
+ raise ValueError(
186
+ f"thinking.arg 不得使用请求体保留键: {value}"
187
+ f"(禁用: {', '.join(sorted(_RESERVED_PAYLOAD_KEYS))})"
188
+ )
189
+ return value
190
+
191
+ @model_validator(mode="after")
192
+ def _validate_off(self) -> Self:
193
+ """``off`` 必须给出关闭取值(缺省与显式 null 都拒绝)。"""
194
+ if self.off is None:
195
+ raise ValueError("thinking.off 不得为空(须给出关闭思考时的取值)")
196
+ return self
197
+
198
+
199
+ def _build_thinking_spec(
200
+ config: ThinkingSwitchSpecModel | None,
201
+ ) -> ThinkingSwitchSpec | None:
202
+ """静态声明 → 运行时 spec(未声明返回 None,表示不支持按调用切换)。"""
203
+ if config is None:
204
+ return None
205
+ return ThinkingSwitchSpec(
206
+ arg=config.arg, off=config.off, on=config.on, default=config.default
207
+ )
133
208
 
134
209
 
135
210
  class ProfileConfig(BaseModel):
@@ -151,6 +226,8 @@ class ProfileConfig(BaseModel):
151
226
  # 厂商专有请求字段透传(如 {"thinking": {"type": "disabled"}});
152
227
  # 与服务构造的同名键冲突时以服务的值为准,故保留键无法被覆盖
153
228
  extra_body: dict[str, Any] | None = None
229
+ # 思考开关注入声明:逐调用 thinking= 据此翻译为厂商字段(未声明 = 不支持切换)
230
+ thinking: ThinkingSwitchSpecModel | None = None
154
231
 
155
232
 
156
233
  class TypeConfig(BaseModel):
@@ -285,6 +362,7 @@ class ModelRegistry:
285
362
  dimension=config.dimension,
286
363
  batch_size=config.batch_size,
287
364
  extra_body=config.extra_body,
365
+ thinking=_build_thinking_spec(config.thinking),
288
366
  )
289
367
 
290
368
  def get_profile(self, model_type: str, profile: str | None = None) -> ProfileConfig:
@@ -319,11 +319,14 @@ class _BackgroundLoop:
319
319
  q: queue.Queue[object],
320
320
  *,
321
321
  max_tokens: int | None = None,
322
+ thinking: bool | None = None,
322
323
  stats: StreamStats,
323
324
  ) -> concurrent.futures.Future[None]:
324
325
  """投递流式收集任务:后台拉取链式 ``stream`` 帧写入线程安全队列。
325
326
 
326
327
  Args:
328
+ thinking: 思考开关(None 不干预);
329
+ 结果记在 ``stats.thinking_applied``
327
330
  stats: 收尾统计累加器(由同步侧持有并读取)
328
331
 
329
332
  返回的 future 可取消,用于同步侧中断时停止后台拉取。
@@ -338,6 +341,7 @@ class _BackgroundLoop:
338
341
  async for delta in service.stream(
339
342
  messages,
340
343
  max_tokens=max_tokens,
344
+ thinking=thinking,
341
345
  first_frame_timeout=_STREAM_FIRST_FRAME_TIMEOUT,
342
346
  stats=stats,
343
347
  ):
@@ -472,21 +476,30 @@ async def _chat_ask(
472
476
  messages: list[dict[str, str]],
473
477
  *,
474
478
  max_tokens: int | None = None,
479
+ thinking: bool | None = None,
475
480
  deadline: float | None = None,
476
481
  ) -> str:
477
482
  service: FailoverChatService = cast(
478
483
  FailoverChatService, await _background._get_service(_CHAT) # noqa: SLF001
479
484
  )
480
- return await service.ask(messages, max_tokens=max_tokens, deadline=deadline)
485
+ return await service.ask(
486
+ messages, max_tokens=max_tokens, thinking=thinking, deadline=deadline
487
+ )
481
488
 
482
489
 
483
490
  async def _chat_complete(
484
- prompt: str, *, max_tokens: int | None = None, deadline: float | None = None
491
+ prompt: str,
492
+ *,
493
+ max_tokens: int | None = None,
494
+ thinking: bool | None = None,
495
+ deadline: float | None = None,
485
496
  ) -> str:
486
497
  service: FailoverChatService = cast(
487
498
  FailoverChatService, await _background._get_service(_CHAT) # noqa: SLF001
488
499
  )
489
- return await service.complete(prompt, max_tokens=max_tokens, deadline=deadline)
500
+ return await service.complete(
501
+ prompt, max_tokens=max_tokens, thinking=thinking, deadline=deadline
502
+ )
490
503
 
491
504
 
492
505
  async def _embed_texts(
@@ -534,34 +547,47 @@ class AI:
534
547
  """kit/ai 同步门面:在同步上下文调用异步 AI 服务(候选链故障切换)。"""
535
548
 
536
549
  @staticmethod
537
- def chat_complete(prompt: str, *, max_tokens: int | None = None) -> str:
550
+ def chat_complete(
551
+ prompt: str, *, max_tokens: int | None = None, thinking: bool | None = None
552
+ ) -> str:
538
553
  """单轮对话(仅 user 消息),返回模型回答文本。
539
554
 
540
555
  Args:
541
556
  max_tokens: 输出上限;None 时回落档案 ``max_tokens`` 配置,
542
557
  仍未配置则由 provider 默认值决定
558
+ thinking: 思考开关;None 不干预(按档案 ``thinking.default``,
559
+ 未配置则请求体不变)/ True 开 / False 关。档案未声明该能力时
560
+ 忽略并告警(不报错、不影响故障切换)
543
561
  """
544
562
  timeout = _facade_timeout(_CHAT)
545
563
  deadline = time.monotonic() + timeout
546
564
  return _background.run(
547
- lambda: _chat_complete(prompt, max_tokens=max_tokens, deadline=deadline),
565
+ lambda: _chat_complete(
566
+ prompt, max_tokens=max_tokens, thinking=thinking, deadline=deadline
567
+ ),
548
568
  timeout=timeout,
549
569
  )
550
570
 
551
571
  @staticmethod
552
572
  def chat_ask(
553
- messages: list[dict[str, str]], *, max_tokens: int | None = None
573
+ messages: list[dict[str, str]],
574
+ *,
575
+ max_tokens: int | None = None,
576
+ thinking: bool | None = None,
554
577
  ) -> str:
555
578
  """多轮对话(自组装 messages),返回模型回答文本。
556
579
 
557
580
  Args:
558
581
  max_tokens: 输出上限;None 时回落档案 ``max_tokens`` 配置,
559
582
  仍未配置则由 provider 默认值决定
583
+ thinking: 思考开关;语义同 :meth:`chat_complete`
560
584
  """
561
585
  timeout = _facade_timeout(_CHAT)
562
586
  deadline = time.monotonic() + timeout
563
587
  return _background.run(
564
- lambda: _chat_ask(messages, max_tokens=max_tokens, deadline=deadline),
588
+ lambda: _chat_ask(
589
+ messages, max_tokens=max_tokens, thinking=thinking, deadline=deadline
590
+ ),
565
591
  timeout=timeout,
566
592
  )
567
593
 
@@ -667,11 +693,19 @@ class AI:
667
693
  messages: list[dict[str, str]],
668
694
  *,
669
695
  max_tokens: int | None = None,
696
+ thinking: bool | None = None,
670
697
  ) -> Iterator[str]:
671
698
  """多轮对话(流式),返回逐 token 迭代器(同步)。
672
699
 
673
700
  在同步线程内逐帧消费,适合流式输出;内部在后台 loop 持续拉取
674
- 并放入线程安全队列。超时保护:
701
+ 并放入线程安全队列。
702
+
703
+ Args:
704
+ thinking: 思考开关;None 不干预(按档案 ``thinking.default``)/
705
+ True 开 / False 关。是否真正生效读 ``stream.thinking_applied``
706
+ (None 未干预 / True 已注入 / False 被忽略)
707
+
708
+ 超时保护:
675
709
  - 首帧前:每候选 ``_STREAM_FIRST_FRAME_TIMEOUT``(60s)内**一帧
676
710
  都没有**(含推理帧)则切下一候选,全部候选耗尽抛
677
711
  ``AITimeoutError``;思考型模型的推理帧算活动、会重置窗口;
@@ -687,14 +721,15 @@ class AI:
687
721
  break
688
722
 
689
723
  流结束后可读 ``reasoning_chars`` / ``content_chars`` / ``finish_reason`` /
690
- ``truncated``,用于区分「模型没返回内容」与「输出预算被思考吃光」::
724
+ ``truncated`` / ``thinking_applied``,用于区分「模型没返回内容」与
725
+ 「输出预算被思考吃光」,以及确认思考开关是否真的生效::
691
726
 
692
727
  with AI.chat_stream(messages, max_tokens=450) as stream:
693
728
  body = "".join(stream)
694
729
  if not body and stream.truncated and stream.reasoning_chars:
695
- ... # 推理吃光预算:调大 max_tokens 或关闭思考
730
+ ... # 推理吃光预算:调大 max_tokens 或传 thinking=False
696
731
  """
697
- return _StreamIterator(messages, max_tokens=max_tokens)
732
+ return _StreamIterator(messages, max_tokens=max_tokens, thinking=thinking)
698
733
 
699
734
 
700
735
  class _StreamIterator:
@@ -710,15 +745,21 @@ class _StreamIterator:
710
745
  兜底取消后台 producer,避免任务残留。
711
746
 
712
747
  流结束后可读 ``reasoning_chars`` / ``content_chars`` / ``finish_reason`` /
713
- ``truncated`` 四个只读属性(跨线程共享同一累加器:结束后为最终值,
714
- 流式过程中为尽力而为的中间值;提前 ``break`` 时为截至中断的累计值)。
748
+ ``truncated`` / ``thinking_applied`` 五个只读属性(跨线程共享同一累加器:
749
+ 结束后为最终值,流式过程中为尽力而为的中间值;提前 ``break`` 时为截至
750
+ 中断的累计值)。
715
751
  """
716
752
 
717
753
  def __init__(
718
- self, messages: list[dict[str, str]], *, max_tokens: int | None = None
754
+ self,
755
+ messages: list[dict[str, str]],
756
+ *,
757
+ max_tokens: int | None = None,
758
+ thinking: bool | None = None,
719
759
  ) -> None:
720
760
  self._messages: list[dict[str, str]] = messages
721
761
  self._max_tokens: int | None = max_tokens
762
+ self._thinking: bool | None = thinking
722
763
  self._q: queue.Queue[object] = queue.Queue()
723
764
  self._stats: StreamStats = StreamStats()
724
765
  self._started = False
@@ -736,6 +777,7 @@ class _StreamIterator:
736
777
  self._messages,
737
778
  self._q,
738
779
  max_tokens=self._max_tokens,
780
+ thinking=self._thinking,
739
781
  stats=self._stats,
740
782
  )
741
783
 
@@ -811,6 +853,11 @@ class _StreamIterator:
811
853
  """是否因输出长度上限被截断(``finish_reason`` 为 length / max_tokens)。"""
812
854
  return self._stats.truncated
813
855
 
856
+ @property
857
+ def thinking_applied(self) -> bool | None:
858
+ """思考开关是否生效:None 未要求干预 / True 已注入 / False 被忽略。"""
859
+ return self._stats.thinking_applied
860
+
814
861
 
815
862
  async def _delayed_close(services: list[Service]) -> None:
816
863
  """延迟回收旧 service:给在途调用留宽限期,到期统一关闭(须在 loop 内)。"""
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes