sdpy-kit 0.6.5__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.
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/PKG-INFO +55 -9
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/README.md +54 -8
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/__init__.py +1 -1
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/base_service.py +17 -1
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/chat_service.py +122 -8
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/failover.py +12 -1
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/pool.py +2 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/profiles.py +80 -2
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/runner.py +61 -14
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/.gitignore +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/__init__.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/exceptions.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/model_builder.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/model_client.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/rerank_service.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/ai/types.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/common_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/crawl/article_io.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/crawl/deepseek_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/__init__.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/audit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/base_db_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/crud.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/engine.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/query.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/session.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/docs/md2doc_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/file_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/laya_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/license_kit.py +0 -0
- {sdpy_kit-0.6.5 → sdpy_kit-0.6.7}/kit/redis_kit.py +0 -0
- {sdpy_kit-0.6.5 → 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.
|
|
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
|
-
|
|
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,20 +814,49 @@ 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
|
|
|
821
821
|
- 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
|
|
822
|
+
- 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` 判定是否被截断;
|
|
822
823
|
- 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
|
|
823
824
|
- `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
|
|
824
825
|
- **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
|
|
825
826
|
- provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
|
|
826
827
|
- 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
|
|
827
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
|
+
|
|
828
852
|
> 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
|
|
829
853
|
> 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
|
|
830
854
|
|
|
855
|
+
> **非流式(`ask` / `complete`)的同款诊断**:provider 在截断时可能完全不返回 `content` 键,
|
|
856
|
+
> 此时 kit 抛 `AIResponseError`,消息里带 `finish_reason` 与推理字符数(不再只报「content
|
|
857
|
+
> 类型异常」,可直接判断是否被思考吃光);正文为空串时返回 `""` 并打 `WARNING`。
|
|
858
|
+
> 返回类型(`str`)与异常类型均未改变。
|
|
859
|
+
|
|
831
860
|
> 门面仅面向**同步上下文**;若调用点本身在 async 函数内,请直接 `await` 原生异步服务,避免阻塞事件循环线程。
|
|
832
861
|
> 模块级另有 `kit.ai.runner.reload_config(...)`、`run_sync(coro_factory, *, timeout=120.0)`、`shutdown()`。
|
|
833
862
|
|
|
@@ -847,7 +876,7 @@ await service.close()
|
|
|
847
876
|
|
|
848
877
|
| 类 | 方法 |
|
|
849
878
|
|----|------|
|
|
850
|
-
| `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` 语义与门面一致 |
|
|
851
880
|
| `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
|
|
852
881
|
| `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
|
|
853
882
|
| `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
|
|
@@ -1380,9 +1409,26 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
|
|
|
1380
1409
|
| 档案级 | `timeout` | 请求超时秒数(默认 60) |
|
|
1381
1410
|
| 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
|
|
1382
1411
|
| 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
|
|
1383
|
-
| 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens
|
|
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 只做翻译,不含任何厂商名 |
|
|
1384
1414
|
| 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
|
|
1385
1415
|
|
|
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", "<档案名>")` 是进程级切换,非并发安全)。
|
|
1431
|
+
|
|
1386
1432
|
### 候选链切换语义(两类,勿混淆)
|
|
1387
1433
|
|
|
1388
1434
|
- **跨调用切换**:`AI.use()` 与其它请求产生的失败冷却均只影响**下一次**请求——每次调用的候选计划在开始时定格;
|
|
@@ -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
|
-
|
|
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,20 +738,49 @@ 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
|
|
|
745
745
|
- 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
|
|
746
|
+
- 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` 判定是否被截断;
|
|
746
747
|
- 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
|
|
747
748
|
- `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
|
|
748
749
|
- **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
|
|
749
750
|
- provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
|
|
750
751
|
- 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
|
|
751
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
|
+
|
|
752
776
|
> 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
|
|
753
777
|
> 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
|
|
754
778
|
|
|
779
|
+
> **非流式(`ask` / `complete`)的同款诊断**:provider 在截断时可能完全不返回 `content` 键,
|
|
780
|
+
> 此时 kit 抛 `AIResponseError`,消息里带 `finish_reason` 与推理字符数(不再只报「content
|
|
781
|
+
> 类型异常」,可直接判断是否被思考吃光);正文为空串时返回 `""` 并打 `WARNING`。
|
|
782
|
+
> 返回类型(`str`)与异常类型均未改变。
|
|
783
|
+
|
|
755
784
|
> 门面仅面向**同步上下文**;若调用点本身在 async 函数内,请直接 `await` 原生异步服务,避免阻塞事件循环线程。
|
|
756
785
|
> 模块级另有 `kit.ai.runner.reload_config(...)`、`run_sync(coro_factory, *, timeout=120.0)`、`shutdown()`。
|
|
757
786
|
|
|
@@ -771,7 +800,7 @@ await service.close()
|
|
|
771
800
|
|
|
772
801
|
| 类 | 方法 |
|
|
773
802
|
|----|------|
|
|
774
|
-
| `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` 语义与门面一致 |
|
|
775
804
|
| `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
|
|
776
805
|
| `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
|
|
777
806
|
| `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
|
|
@@ -1304,9 +1333,26 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
|
|
|
1304
1333
|
| 档案级 | `timeout` | 请求超时秒数(默认 60) |
|
|
1305
1334
|
| 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
|
|
1306
1335
|
| 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
|
|
1307
|
-
| 档案级 | `extra_body` | 厂商专有请求体字段透传(如 GLM/Qwen 关闭思考的 `{"thinking": {"type": "disabled"}}`、`{"reasoning_effort": "low"}`);与服务构造的同名键冲突时**以服务的值为准**,故 `model` / `messages` / `stream` 等保留键不可覆盖;改名类需求(如 OpenAI o 系列的 `max_completion_tokens
|
|
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 只做翻译,不含任何厂商名 |
|
|
1308
1338
|
| 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
|
|
1309
1339
|
|
|
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", "<档案名>")` 是进程级切换,非并发安全)。
|
|
1355
|
+
|
|
1310
1356
|
### 候选链切换语义(两类,勿混淆)
|
|
1311
1357
|
|
|
1312
1358
|
- **跨调用切换**:`AI.use()` 与其它请求产生的失败冷却均只影响**下一次**请求——每次调用的候选计划在开始时定格;
|
|
@@ -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.
|
|
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
|
|
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
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
|
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(
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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]],
|
|
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(
|
|
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
|
-
|
|
748
|
+
``truncated`` / ``thinking_applied`` 五个只读属性(跨线程共享同一累加器:
|
|
749
|
+
结束后为最终值,流式过程中为尽力而为的中间值;提前 ``break`` 时为截至
|
|
750
|
+
中断的累计值)。
|
|
715
751
|
"""
|
|
716
752
|
|
|
717
753
|
def __init__(
|
|
718
|
-
self,
|
|
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
|
|
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
|