sdpy-kit 0.6.4__tar.gz → 0.6.6__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 (55) hide show
  1. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/PKG-INFO +43 -4
  2. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/README.md +42 -3
  3. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/__init__.py +2 -1
  5. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/base_service.py +15 -2
  6. sdpy_kit-0.6.6/kit/ai/chat_service.py +266 -0
  7. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/failover.py +66 -19
  8. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/profiles.py +9 -1
  9. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/runner.py +48 -3
  10. sdpy_kit-0.6.4/kit/ai/chat_service.py +0 -160
  11. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/.gitignore +0 -0
  12. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/embedding_service.py +0 -0
  13. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/exceptions.py +0 -0
  14. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/image_service.py +0 -0
  15. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/model_builder.py +0 -0
  16. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/model_client.py +0 -0
  17. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/pool.py +0 -0
  18. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/rerank_service.py +0 -0
  19. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/ai/types.py +0 -0
  20. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/common_kit.py +0 -0
  21. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/config/__init__.py +0 -0
  22. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/config/kit_config.py +0 -0
  23. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/config/log_config.py +0 -0
  24. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/crawl/__init__.py +0 -0
  25. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/crawl/article_io.py +0 -0
  26. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/crawl/article_models.py +0 -0
  27. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/crawl/deepseek_kit.py +0 -0
  28. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/crawl/web_article_kit.py +0 -0
  29. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/crawl/wx_article_kit.py +0 -0
  30. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/__init__.py +0 -0
  31. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/audit.py +0 -0
  32. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/base_db_kit.py +0 -0
  33. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/crud.py +0 -0
  34. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/engine.py +0 -0
  35. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/exceptions.py +0 -0
  36. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/models/__init__.py +0 -0
  37. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/models/base.py +0 -0
  38. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/mysql_kit.py +0 -0
  39. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/pg_kit.py +0 -0
  40. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/query.py +0 -0
  41. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/session.py +0 -0
  42. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/sqlite_kit.py +0 -0
  43. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/db/transaction.py +0 -0
  44. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/docs/__init__.py +0 -0
  45. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/docs/anydoc_kit.py +0 -0
  46. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/docs/docx_kit.py +0 -0
  47. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/docs/marker_kit.py +0 -0
  48. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/docs/md2doc_kit.py +0 -0
  49. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/docs/mineru_kit.py +0 -0
  50. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/docs/rc_docparser_kit.py +0 -0
  51. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/file_kit.py +0 -0
  52. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/laya_kit.py +0 -0
  53. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/license_kit.py +0 -0
  54. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/kit/redis_kit.py +0 -0
  55. {sdpy_kit-0.6.4 → sdpy_kit-0.6.6}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.6.4
3
+ Version: 0.6.6
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -785,7 +785,7 @@ shutdown() # 应用退出前
785
785
  |------|------|------|
786
786
  | chat | `chat_complete(prompt, *, max_tokens=None) -> str` | 单轮对话(仅 user 消息) |
787
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` 使用) |
788
+ | chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
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]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
@@ -802,6 +802,38 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
802
802
  print(token, end="", flush=True)
803
803
  ```
804
804
 
805
+ #### 流式收尾统计(思考型模型必备)
806
+
807
+ 迭代器在流结束后可读四个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
808
+ `truncated`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
809
+ token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
810
+
811
+ ```python
812
+ with AI.chat_stream(messages, max_tokens=450) as stream:
813
+ body = "".join(stream)
814
+ if not body and stream.truncated and stream.reasoning_chars:
815
+ raise ValueError(
816
+ f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
817
+ "请调大 max_tokens 或改用关闭思考的档案"
818
+ )
819
+ ```
820
+
821
+ - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
822
+ - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` 判定是否被截断;
823
+ - 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
824
+ - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
825
+ - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
826
+ - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
827
+ - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
828
+
829
+ > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
830
+ > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
831
+
832
+ > **非流式(`ask` / `complete`)的同款诊断**:provider 在截断时可能完全不返回 `content` 键,
833
+ > 此时 kit 抛 `AIResponseError`,消息里带 `finish_reason` 与推理字符数(不再只报「content
834
+ > 类型异常」,可直接判断是否被思考吃光);正文为空串时返回 `""` 并打 `WARNING`。
835
+ > 返回类型(`str`)与异常类型均未改变。
836
+
805
837
  > 门面仅面向**同步上下文**;若调用点本身在 async 函数内,请直接 `await` 原生异步服务,避免阻塞事件循环线程。
806
838
  > 模块级另有 `kit.ai.runner.reload_config(...)`、`run_sync(coro_factory, *, timeout=120.0)`、`shutdown()`。
807
839
 
@@ -811,7 +843,7 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
811
843
  from kit.ai import (
812
844
  ChatService, EmbeddingService, RerankService, ImageService, # 服务类
813
845
  ChatRequest, EmbeddingRequest, RerankRequest, ImageRequest, # 请求模型
814
- RerankHit, ModelBuilder, # 结果类型 / 构建器
846
+ RerankHit, StreamStats, ModelBuilder, # 结果类型 / 构建器
815
847
  )
816
848
 
817
849
  service = ModelBuilder.build_chat() # 老按类型构建:build_chat / build_embedding / build_rerank / build_image
@@ -821,7 +853,7 @@ await service.close()
821
853
 
822
854
  | 类 | 方法 |
823
855
  |----|------|
824
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ...) -> AsyncIterator[str]` |
856
+ | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
825
857
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
826
858
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
827
859
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1354,8 +1386,15 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1354
1386
  | 档案级 | `timeout` | 请求超时秒数(默认 60) |
1355
1387
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1356
1388
  | 档案级 | `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)` 生效 |
1357
1390
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1358
1391
 
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
+ > 临时切换(进程级、非并发安全)。
1397
+
1359
1398
  ### 候选链切换语义(两类,勿混淆)
1360
1399
 
1361
1400
  - **跨调用切换**:`AI.use()` 与其它请求产生的失败冷却均只影响**下一次**请求——每次调用的候选计划在开始时定格;
@@ -709,7 +709,7 @@ shutdown() # 应用退出前
709
709
  |------|------|------|
710
710
  | chat | `chat_complete(prompt, *, max_tokens=None) -> str` | 单轮对话(仅 user 消息) |
711
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` 使用) |
712
+ | chat | `chat_stream(messages, *, max_tokens=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` |
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]]` | 返回 `[(原索引, 相关分)]` 降序列表 |
@@ -726,6 +726,38 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
726
726
  print(token, end="", flush=True)
727
727
  ```
728
728
 
729
+ #### 流式收尾统计(思考型模型必备)
730
+
731
+ 迭代器在流结束后可读四个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
732
+ `truncated`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
733
+ token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」:
734
+
735
+ ```python
736
+ with AI.chat_stream(messages, max_tokens=450) as stream:
737
+ body = "".join(stream)
738
+ if not body and stream.truncated and stream.reasoning_chars:
739
+ raise ValueError(
740
+ f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
741
+ "请调大 max_tokens 或改用关闭思考的档案"
742
+ )
743
+ ```
744
+
745
+ - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
746
+ - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` 判定是否被截断;
747
+ - 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
748
+ - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
749
+ - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
750
+ - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
751
+ - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
752
+
753
+ > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
754
+ > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
755
+
756
+ > **非流式(`ask` / `complete`)的同款诊断**:provider 在截断时可能完全不返回 `content` 键,
757
+ > 此时 kit 抛 `AIResponseError`,消息里带 `finish_reason` 与推理字符数(不再只报「content
758
+ > 类型异常」,可直接判断是否被思考吃光);正文为空串时返回 `""` 并打 `WARNING`。
759
+ > 返回类型(`str`)与异常类型均未改变。
760
+
729
761
  > 门面仅面向**同步上下文**;若调用点本身在 async 函数内,请直接 `await` 原生异步服务,避免阻塞事件循环线程。
730
762
  > 模块级另有 `kit.ai.runner.reload_config(...)`、`run_sync(coro_factory, *, timeout=120.0)`、`shutdown()`。
731
763
 
@@ -735,7 +767,7 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
735
767
  from kit.ai import (
736
768
  ChatService, EmbeddingService, RerankService, ImageService, # 服务类
737
769
  ChatRequest, EmbeddingRequest, RerankRequest, ImageRequest, # 请求模型
738
- RerankHit, ModelBuilder, # 结果类型 / 构建器
770
+ RerankHit, StreamStats, ModelBuilder, # 结果类型 / 构建器
739
771
  )
740
772
 
741
773
  service = ModelBuilder.build_chat() # 老按类型构建:build_chat / build_embedding / build_rerank / build_image
@@ -745,7 +777,7 @@ await service.close()
745
777
 
746
778
  | 类 | 方法 |
747
779
  |----|------|
748
- | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ...) -> AsyncIterator[str]` |
780
+ | `ChatService` | `ask(messages, *, temperature=None, max_tokens=None, deadline=None) -> str`、`complete(prompt, *, ...) -> str`、`stream(messages, *, ..., stats: StreamStats | None = None) -> AsyncIterator[str]`(传 `StreamStats()` 即可读收尾统计) |
749
781
  | `EmbeddingService` | `embed_text(text) -> list[float]`、`embed_texts(texts, *, batch_size=None, concurrency=4) -> list[list[float]]`、`dimension` 属性(**目标维度**,见下) |
750
782
  | `RerankService` | `rerank(query, documents, *, top_n=None) -> list[RerankHit]`(已按 score 降序) |
751
783
  | `ImageService` | `generate(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> str` |
@@ -1278,8 +1310,15 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1278
1310
  | 档案级 | `timeout` | 请求超时秒数(默认 60) |
1279
1311
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1280
1312
  | 档案级 | `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)` 生效 |
1281
1314
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1282
1315
 
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
+ > 临时切换(进程级、非并发安全)。
1321
+
1283
1322
  ### 候选链切换语义(两类,勿混淆)
1284
1323
 
1285
1324
  - **跨调用切换**:`AI.use()` 与其它请求产生的失败冷却均只影响**下一次**请求——每次调用的候选计划在开始时定格;
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.6.4"
26
+ __version__ = "0.6.6"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -27,7 +27,7 @@ Author: Clark Chang
27
27
  - ImageService: 文生图
28
28
  """
29
29
 
30
- from kit.ai.chat_service import ChatRequest, ChatService
30
+ from kit.ai.chat_service import ChatRequest, ChatService, StreamStats
31
31
  from kit.ai.embedding_service import EmbeddingRequest, EmbeddingService
32
32
  from kit.ai.image_service import ImageRequest, ImageService
33
33
  from kit.ai.model_builder import ModelBuilder
@@ -44,4 +44,5 @@ __all__ = [
44
44
  "RerankHit",
45
45
  "RerankRequest",
46
46
  "RerankService",
47
+ "StreamStats",
47
48
  ]
@@ -20,7 +20,7 @@ Author: Clark Chang
20
20
  import json
21
21
  import time
22
22
  from abc import ABC, abstractmethod
23
- from typing import ClassVar, Final, Self, override
23
+ from typing import ClassVar, Final, Self, cast, override
24
24
 
25
25
  from kit.ai.exceptions import AIError, AIResponseError
26
26
  from kit.ai.model_client import ModelClient
@@ -109,6 +109,19 @@ class BaseAIService[RequestT, ResponseT](ABC):
109
109
  def _build_payload(self, request: RequestT) -> JSONObject:
110
110
  """将请求模型构造为 JSON 请求体。"""
111
111
 
112
+ def _apply_extra_body(self, payload: JSONObject) -> JSONObject:
113
+ """合并档案级 ``extra_body``(厂商专有请求字段透传)。
114
+
115
+ 同名键以服务构造的值为准——``model`` / ``messages`` / ``stream`` /
116
+ ``input`` / ``prompt`` 等保留键**不可被档案覆盖**,故无需白名单;
117
+ 未配置时原样返回(请求体逐字节不变)。
118
+ """
119
+ extra = self._profile.extra_body
120
+ if not extra:
121
+ return payload
122
+ merged: dict[str, object] = {**extra, **payload}
123
+ return cast(JSONObject, merged)
124
+
112
125
  @abstractmethod
113
126
  def _parse_response(self, data: JSONObject) -> ResponseT:
114
127
  """将 JSON 响应体解析为业务结果。"""
@@ -124,7 +137,7 @@ class BaseAIService[RequestT, ResponseT](ABC):
124
137
  ``post_json`` 的重试预算检查;None 表示不限制
125
138
  """
126
139
  desc = self._log_desc()
127
- payload = self._build_payload(request)
140
+ payload = self._apply_extra_body(self._build_payload(request))
128
141
  params = _truncate(
129
142
  json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
130
143
  )
@@ -0,0 +1,266 @@
1
+ """
2
+ Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+
16
+ Project: sdpy
17
+ Author: Clark Chang
18
+ """
19
+
20
+ import json
21
+ import time
22
+ from collections.abc import AsyncIterator
23
+ from dataclasses import dataclass
24
+ from typing import ClassVar, Final, cast, override
25
+
26
+ from kit.ai.base_service import (
27
+ _LOG_PAYLOAD_LIMIT,
28
+ BaseAIService,
29
+ _clip,
30
+ _truncate,
31
+ safe_get,
32
+ )
33
+ from kit.ai.exceptions import AIError, AIResponseError
34
+ from kit.ai.types import JSONObject
35
+ from kit.config.log_config import get_default_logger
36
+
37
+ __all__ = ["ChatRequest", "ChatService", "StreamStats"]
38
+
39
+ logger = get_default_logger(__name__)
40
+
41
+ Message = dict[str, str]
42
+
43
+ # 推理(思考)内容可能的字段名:GLM / DeepSeek / Qwen 用 reasoning_content,
44
+ # sensenova 用 reasoning(流式为 delta.*,非流式为 message.*)
45
+ _REASONING_KEYS: Final[tuple[str, ...]] = ("reasoning_content", "reasoning")
46
+
47
+ # 截断判定:OpenAI 系给 length,部分兼容端点给 max_tokens
48
+ _TRUNCATED_REASONS: Final[frozenset[str]] = frozenset({"length", "max_tokens"})
49
+
50
+
51
+ @dataclass
52
+ class StreamStats:
53
+ """对话收尾统计(可变累加器:调用方传入,kit 累加)。
54
+
55
+ 跨线程共享(后台事件循环写、业务线程读):**流结束后为最终值**,
56
+ 流式过程中读到的是尽力而为的中间值。
57
+
58
+ ``reasoning_chars`` / ``content_chars`` 均为**字符数**,与 ``max_tokens``
59
+ (token)不可换算(实测中文约 2~2.5 字符/token,且各 provider 口径不一),
60
+ 仅用于「是否发生思考 / 是否被长度截断」的判定。
61
+
62
+ Attributes:
63
+ reasoning_chars: 推理(思考)内容字符数;非思考模型恒为 0
64
+ content_chars: 已产出正文字符数(等于调用方收到的字符数)
65
+ finish_reason: 结束原因(stop / length / content_filter / …);
66
+ provider 未给出时为 None
67
+ """
68
+
69
+ reasoning_chars: int = 0
70
+ content_chars: int = 0
71
+ finish_reason: str | None = None
72
+
73
+ @property
74
+ def truncated(self) -> bool:
75
+ """是否因输出长度上限被截断。"""
76
+ return self.finish_reason in _TRUNCATED_REASONS
77
+
78
+ def reset(self) -> None:
79
+ """清零(候选链切换到下一个档案前调用)。"""
80
+ self.reasoning_chars = 0
81
+ self.content_chars = 0
82
+ self.finish_reason = None
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class ChatRequest:
87
+ """对话请求模型。"""
88
+
89
+ messages: list[Message]
90
+ temperature: float | None = None
91
+ max_tokens: int | None = None
92
+ stream: bool = False
93
+
94
+
95
+ class ChatService(BaseAIService[ChatRequest, str]):
96
+ """对话类模型服务(全异步)。"""
97
+
98
+ _LABEL: ClassVar[str] = "对话模型"
99
+
100
+ @override
101
+ def _build_payload(self, request: ChatRequest) -> JSONObject:
102
+ payload: dict[str, object] = {
103
+ "model": self._profile.model,
104
+ "messages": request.messages,
105
+ "stream": request.stream,
106
+ }
107
+ if request.temperature is not None:
108
+ payload["temperature"] = request.temperature
109
+ # max_tokens 逐级回落:调用点显式传入 → 档案配置 → 不写(provider 默认)
110
+ max_tokens = (
111
+ request.max_tokens
112
+ if request.max_tokens is not None
113
+ else self._profile.max_tokens
114
+ )
115
+ if max_tokens is not None:
116
+ payload["max_tokens"] = max_tokens
117
+ return cast(JSONObject, payload)
118
+
119
+ @override
120
+ def _parse_response(self, data: JSONObject) -> str:
121
+ choice = safe_get(data, "choices", 0)
122
+ if not isinstance(choice, dict):
123
+ raise AIResponseError(f"choices[0] 类型异常: {type(choice).__name__}")
124
+ message = choice.get("message")
125
+ content = message.get("content") if isinstance(message, dict) else None
126
+ finish = choice.get("finish_reason")
127
+ reasoning = 0
128
+ if isinstance(message, dict):
129
+ for key in _REASONING_KEYS:
130
+ part = message.get(key)
131
+ if isinstance(part, str) and part:
132
+ reasoning = len(part)
133
+ break
134
+ if not isinstance(content, str):
135
+ if finish in _TRUNCATED_REASONS and reasoning:
136
+ # 思考型模型截断时可能完全不返回 content 键(实测 sensenova)
137
+ raise AIResponseError(
138
+ f"输出预算被思考耗尽:finish_reason={finish},"
139
+ f"推理 {reasoning} 字符、正文为空"
140
+ "(请调大 max_tokens 或在档案 extra_body 中关闭思考)"
141
+ )
142
+ raise AIResponseError(f"content 类型异常: {type(content).__name__}")
143
+ if finish in _TRUNCATED_REASONS and (reasoning or not content):
144
+ logger.warning(
145
+ f"{self._log_desc()} 输出被长度截断(finish_reason={finish}):"
146
+ f"推理 {reasoning} 字符 / 正文 {len(content)} 字符"
147
+ "(可调大 max_tokens 或在档案 extra_body 中关闭思考)"
148
+ )
149
+ return content
150
+
151
+ async def ask(
152
+ self,
153
+ messages: list[Message],
154
+ *,
155
+ temperature: float | None = None,
156
+ max_tokens: int | None = None,
157
+ deadline: float | None = None,
158
+ ) -> str:
159
+ """多轮对话,返回模型回答文本。
160
+
161
+ Args:
162
+ deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
163
+ 5xx 重试预算;None 表示不限制
164
+ """
165
+ request = ChatRequest(messages, temperature=temperature, max_tokens=max_tokens)
166
+ return await self._call(request, deadline=deadline)
167
+
168
+ async def complete(
169
+ self,
170
+ prompt: str,
171
+ *,
172
+ temperature: float | None = None,
173
+ max_tokens: int | None = None,
174
+ deadline: float | None = None,
175
+ ) -> str:
176
+ messages: list[Message] = [{"role": "user", "content": prompt}]
177
+ return await self.ask(
178
+ messages, temperature=temperature, max_tokens=max_tokens, deadline=deadline
179
+ )
180
+
181
+ async def stream(
182
+ self,
183
+ messages: list[Message],
184
+ *,
185
+ temperature: float | None = None,
186
+ max_tokens: int | None = None,
187
+ stats: StreamStats | None = None,
188
+ ) -> AsyncIterator[str]:
189
+ """流式对话,逐 token 产出正文。
190
+
191
+ Args:
192
+ stats: 收尾统计累加器(可选);None 时内部创建后丢弃——需要
193
+ ``reasoning_chars`` / ``finish_reason`` / ``truncated`` 时
194
+ 由调用方传入一个实例
195
+
196
+ 推理内容(``delta.reasoning_content`` / ``delta.reasoning``)只计数
197
+ 不产出;正文为空且被截断(或发生过思考)时输出 warning。
198
+ """
199
+ request = ChatRequest(
200
+ messages, temperature=temperature, max_tokens=max_tokens, stream=True
201
+ )
202
+ payload = self._apply_extra_body(self._build_payload(request))
203
+ desc = self._log_desc()
204
+ params = _truncate(
205
+ json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
206
+ )
207
+ stats = stats if stats is not None else StreamStats()
208
+ # 惰性 Start:异步生成器体在首次 __anext__ 前不执行,不消费不打日志
209
+ logger.info(f"********** {desc} ********** Start")
210
+ logger.info(f"请求参数: {params}")
211
+ start = time.perf_counter()
212
+ note = ""
213
+ failed = False
214
+ try:
215
+ async for parsed in self._client.stream_json(payload):
216
+ if parsed is None:
217
+ continue
218
+ choices = parsed.get("choices")
219
+ if not isinstance(choices, list) or not choices:
220
+ continue
221
+ choice = choices[0]
222
+ if not isinstance(choice, dict):
223
+ continue
224
+ finish = choice.get("finish_reason")
225
+ if isinstance(finish, str) and finish:
226
+ stats.finish_reason = finish
227
+ delta = choice.get("delta")
228
+ if not isinstance(delta, dict):
229
+ continue
230
+ for key in _REASONING_KEYS:
231
+ part = delta.get(key)
232
+ if isinstance(part, str) and part:
233
+ stats.reasoning_chars += len(part)
234
+ break
235
+ content = delta.get("content")
236
+ if isinstance(content, str) and content:
237
+ stats.content_chars += len(content)
238
+ yield content
239
+ except AIError as exc:
240
+ note = f"(失败:{exc})"
241
+ failed = True
242
+ raise
243
+ except BaseException: # noqa: BLE001 - 中断路径(提前 break/取消)也需收尾日志
244
+ note = "(中断)"
245
+ raise
246
+ else:
247
+ self._warn_if_budget_eaten(stats, payload.get("max_tokens"))
248
+ finally:
249
+ log = logger.error if failed else logger.info
250
+ log(
251
+ f"********** {desc} ********** "
252
+ f"耗时 {time.perf_counter() - start:.1f}秒{note}"
253
+ )
254
+
255
+ def _warn_if_budget_eaten(
256
+ self, stats: StreamStats, max_tokens: object
257
+ ) -> None:
258
+ """正文为空且(被截断或有推理)时告警:讲清「预算被思考吃掉」。"""
259
+ if stats.content_chars or not (stats.truncated or stats.reasoning_chars):
260
+ return
261
+ logger.warning(
262
+ f"{self._log_desc()} 输出预算疑被思考耗尽:max_tokens={max_tokens},"
263
+ f"finish_reason={stats.finish_reason},"
264
+ f"推理 {stats.reasoning_chars} 字符 / 正文 0 字符"
265
+ "(可调大 max_tokens 或在档案 extra_body 中关闭思考)"
266
+ )
@@ -35,9 +35,9 @@ Author: Clark Chang
35
35
  import asyncio
36
36
  import time
37
37
  from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
38
- from typing import TypeVar, cast
38
+ from typing import Final, TypeVar, cast
39
39
 
40
- from kit.ai.chat_service import ChatService
40
+ from kit.ai.chat_service import ChatService, StreamStats
41
41
  from kit.ai.embedding_service import EmbeddingService
42
42
  from kit.ai.exceptions import AIAggregateError, AIError, AIRequestError, AITimeoutError
43
43
  from kit.ai.image_service import ImageService
@@ -57,6 +57,10 @@ T = TypeVar("T")
57
57
  # 批量向量化默认并发(与 EmbeddingService 保持一致)
58
58
  _EMBED_CONCURRENCY = 4
59
59
 
60
+ # 流式首帧等待的轮询粒度(秒):只用于「有无任何帧」的续期判定,
61
+ # 不改变超时时长语义(推理帧被吞掉,不轮询就看不到思考阶段的活动)
62
+ _FIRST_FRAME_POLL: Final[float] = 1.0
63
+
60
64
 
61
65
  class _FailoverBase:
62
66
  """链式调用基类:候选迭代 + 失败冷却 + 整链预算 + 聚合异常。"""
@@ -202,48 +206,44 @@ class FailoverChatService(_FailoverBase):
202
206
  max_tokens: int | None = None,
203
207
  first_frame_timeout: float | None = None,
204
208
  deadline: float | None = None,
209
+ stats: StreamStats | None = None,
205
210
  ) -> AsyncIterator[str]:
206
211
  """流式对话:首帧前失败自动换档案重试;首帧后失败直接抛出。
207
212
 
208
213
  Args:
209
- first_frame_timeout: 单候选首帧等待上限(秒);超时按失败处理
210
- 切下一候选(补齐「首帧前允许切换」的时间兜底);None 不限制
214
+ first_frame_timeout: 单候选首帧等待上限(秒);窗口内**一帧都没有**
215
+ (含推理帧)才按失败处理切下一候选;None 不限制
211
216
  deadline: 整链预算截止时刻;每个候选发起前检查
217
+ stats: 收尾统计累加器;候选切换前清零,故只反映最终成功的候选
212
218
  """
213
219
  tried: list[str] = []
214
220
  errors: list[AIError] = []
215
221
  budget_exhausted = False
222
+ stats = stats if stats is not None else StreamStats()
216
223
  for name, service in self._pool.iter_services(self._model_type):
217
224
  if self._budget_exhausted(deadline):
218
225
  budget_exhausted = True
219
226
  break
220
227
  tried.append(name)
228
+ stats.reset()
221
229
  agen = cast(
222
230
  AsyncGenerator[str, None],
223
231
  cast(ChatService, service).stream(
224
- messages, temperature=temperature, max_tokens=max_tokens
232
+ messages,
233
+ temperature=temperature,
234
+ max_tokens=max_tokens,
235
+ stats=stats,
225
236
  ),
226
237
  )
227
238
  try:
228
239
  try:
229
- if first_frame_timeout is not None:
230
- first = await asyncio.wait_for(
231
- agen.__anext__(), timeout=first_frame_timeout
232
- )
233
- else:
234
- first = await agen.__anext__()
240
+ first = await self._await_first_frame(
241
+ agen, stats, first_frame_timeout
242
+ )
235
243
  except StopAsyncIteration:
236
244
  # 空流(首帧即结束)视为正常完成
237
245
  await agen.aclose()
238
246
  return
239
- except asyncio.TimeoutError:
240
- timeout_exc = AITimeoutError(
241
- f"流式首帧超过 {first_frame_timeout:g}s 未到达"
242
- )
243
- errors.append(timeout_exc)
244
- self._pool.mark_failure(self._model_type, name, timeout_exc)
245
- await agen.aclose()
246
- continue
247
247
  except AIError as exc:
248
248
  errors.append(exc)
249
249
  self._pool.mark_failure(
@@ -270,6 +270,53 @@ class FailoverChatService(_FailoverBase):
270
270
  "流式对话", tried, errors, budget_exhausted=budget_exhausted
271
271
  )
272
272
 
273
+ async def _await_first_frame(
274
+ self,
275
+ agen: AsyncGenerator[str, None],
276
+ stats: StreamStats,
277
+ timeout: float | None,
278
+ ) -> str:
279
+ """等首个正文帧;期间任何帧(含推理帧)都算活动并重置窗口。
280
+
281
+ ``ChatService.stream`` 只产出正文、推理帧仅计数,故「首个 yield 到达」
282
+ 会把整个思考阶段表现为静默——按窗口计时会误判健康的思考型模型失败
283
+ (并重放整段 prompt)。改为按 ``stats.reasoning_chars`` 的推进续期。
284
+
285
+ Raises:
286
+ AITimeoutError: 窗口内一帧都没有
287
+ StopAsyncIteration: 空流(首帧即结束)
288
+ """
289
+ if timeout is None:
290
+ return await agen.__anext__()
291
+ task = asyncio.ensure_future(agen.__anext__())
292
+ waited = 0.0
293
+ seen = stats.reasoning_chars
294
+ try:
295
+ while True:
296
+ remaining = timeout - waited
297
+ if remaining <= 0:
298
+ raise AITimeoutError(f"流式首帧超过 {timeout:g}s 未到达")
299
+ step = min(_FIRST_FRAME_POLL, remaining)
300
+ done, _ = await asyncio.wait({task}, timeout=step)
301
+ if task in done:
302
+ return task.result()
303
+ if stats.reasoning_chars != seen:
304
+ # 推理帧在推进:思考阶段属于正常活动,窗口续期
305
+ seen = stats.reasoning_chars
306
+ waited = 0.0
307
+ continue
308
+ waited += step
309
+ except BaseException:
310
+ if not task.done():
311
+ task.cancel()
312
+ # 等 __anext__ 真正退栈,否则调用方随后 aclose() 会撞
313
+ # 「asynchronous generator is already running」
314
+ try:
315
+ await task
316
+ except BaseException: # noqa: BLE001 - 取消后的收尾无需上抛
317
+ pass
318
+ raise
319
+
273
320
 
274
321
  class FailoverEmbeddingService(_FailoverBase):
275
322
  """向量化服务链式调用(embed_text / embed_texts)。"""
@@ -34,7 +34,7 @@ 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 Final
37
+ from typing import Any, Final
38
38
 
39
39
  from dotenv import load_dotenv
40
40
  from pydantic import BaseModel, Field, ValidationError
@@ -75,6 +75,9 @@ class ModelProfile:
75
75
  保证输出恒等于该值:模型原生维度更大时截断(含 L2 归一化),
76
76
  更小时报错;建表与维度守卫均应以它为唯一来源
77
77
  batch_size: 单次请求最大文本数(仅 embedding 有效)
78
+ extra_body: 档案级额外请求体字段(厂商专有参数透传,如 GLM/Qwen 的
79
+ ``{"thinking": {"type": "disabled"}}``);与服务构造的同名键
80
+ 冲突时**以服务的值为准**(不可覆盖 model / messages / stream 等保留键)
78
81
  """
79
82
 
80
83
  name: str
@@ -89,6 +92,7 @@ class ModelProfile:
89
92
  retries: int = 2
90
93
  dimension: int | None = None
91
94
  batch_size: int = 32
95
+ extra_body: dict[str, Any] | None = None
92
96
 
93
97
  @property
94
98
  def url(self) -> str:
@@ -144,6 +148,9 @@ class ProfileConfig(BaseModel):
144
148
  retries: int = Field(default=2, ge=0)
145
149
  dimension: int | None = Field(default=None, gt=0)
146
150
  batch_size: int = Field(default=32, gt=0)
151
+ # 厂商专有请求字段透传(如 {"thinking": {"type": "disabled"}});
152
+ # 与服务构造的同名键冲突时以服务的值为准,故保留键无法被覆盖
153
+ extra_body: dict[str, Any] | None = None
147
154
 
148
155
 
149
156
  class TypeConfig(BaseModel):
@@ -277,6 +284,7 @@ class ModelRegistry:
277
284
  retries=config.retries,
278
285
  dimension=config.dimension,
279
286
  batch_size=config.batch_size,
287
+ extra_body=config.extra_body,
280
288
  )
281
289
 
282
290
  def get_profile(self, model_type: str, profile: str | None = None) -> ProfileConfig:
@@ -70,6 +70,7 @@ from collections.abc import Callable, Coroutine, Iterable, Iterator
70
70
  from dataclasses import dataclass
71
71
  from typing import Any, Final, TypeVar, cast
72
72
 
73
+ from kit.ai.chat_service import StreamStats
73
74
  from kit.ai.exceptions import AIConfigError, AITimeoutError
74
75
  from kit.ai.failover import (
75
76
  FailoverChatService,
@@ -318,9 +319,13 @@ class _BackgroundLoop:
318
319
  q: queue.Queue[object],
319
320
  *,
320
321
  max_tokens: int | None = None,
322
+ stats: StreamStats,
321
323
  ) -> concurrent.futures.Future[None]:
322
324
  """投递流式收集任务:后台拉取链式 ``stream`` 帧写入线程安全队列。
323
325
 
326
+ Args:
327
+ stats: 收尾统计累加器(由同步侧持有并读取)
328
+
324
329
  返回的 future 可取消,用于同步侧中断时停止后台拉取。
325
330
  """
326
331
  loop = self._ensure_started()
@@ -334,6 +339,7 @@ class _BackgroundLoop:
334
339
  messages,
335
340
  max_tokens=max_tokens,
336
341
  first_frame_timeout=_STREAM_FIRST_FRAME_TIMEOUT,
342
+ stats=stats,
337
343
  ):
338
344
  q.put(delta)
339
345
  except BaseException as exc: # noqa: BLE001 - 异常经队列回传给同步侧
@@ -666,8 +672,9 @@ class AI:
666
672
 
667
673
  在同步线程内逐帧消费,适合流式输出;内部在后台 loop 持续拉取
668
674
  并放入线程安全队列。超时保护:
669
- - 首帧前:每候选 ``_STREAM_FIRST_FRAME_TIMEOUT``(60s)内未出
670
- 首帧则切下一候选,全部候选耗尽抛 ``AITimeoutError``;
675
+ - 首帧前:每候选 ``_STREAM_FIRST_FRAME_TIMEOUT``(60s)内**一帧
676
+ 都没有**(含推理帧)则切下一候选,全部候选耗尽抛
677
+ ``AITimeoutError``;思考型模型的推理帧算活动、会重置窗口;
671
678
  - 首帧后:帧间隔超过 ``_STREAM_FRAME_INTERVAL_TIMEOUT``(120s)
672
679
  未收到新帧则中断并抛 ``AITimeoutError``(已产出 token 不可回滚)。
673
680
 
@@ -678,6 +685,14 @@ class AI:
678
685
  yield token
679
686
  if done:
680
687
  break
688
+
689
+ 流结束后可读 ``reasoning_chars`` / ``content_chars`` / ``finish_reason`` /
690
+ ``truncated``,用于区分「模型没返回内容」与「输出预算被思考吃光」::
691
+
692
+ with AI.chat_stream(messages, max_tokens=450) as stream:
693
+ body = "".join(stream)
694
+ if not body and stream.truncated and stream.reasoning_chars:
695
+ ... # 推理吃光预算:调大 max_tokens 或关闭思考
681
696
  """
682
697
  return _StreamIterator(messages, max_tokens=max_tokens)
683
698
 
@@ -693,6 +708,10 @@ class _StreamIterator:
693
708
  支持 ``with`` 上下文(推荐):退出时主动取消后台任务;即便调用方
694
709
  直接 ``for`` 中途 ``break`` 或迭代器被 GC,``close``/``__del__`` 也会
695
710
  兜底取消后台 producer,避免任务残留。
711
+
712
+ 流结束后可读 ``reasoning_chars`` / ``content_chars`` / ``finish_reason`` /
713
+ ``truncated`` 四个只读属性(跨线程共享同一累加器:结束后为最终值,
714
+ 流式过程中为尽力而为的中间值;提前 ``break`` 时为截至中断的累计值)。
696
715
  """
697
716
 
698
717
  def __init__(
@@ -701,6 +720,7 @@ class _StreamIterator:
701
720
  self._messages: list[dict[str, str]] = messages
702
721
  self._max_tokens: int | None = max_tokens
703
722
  self._q: queue.Queue[object] = queue.Queue()
723
+ self._stats: StreamStats = StreamStats()
704
724
  self._started = False
705
725
  self._closed = False
706
726
  self._got_first = False
@@ -713,7 +733,10 @@ class _StreamIterator:
713
733
  if not self._started:
714
734
  self._started = True
715
735
  self._task = _background.submit_stream(
716
- self._messages, self._q, max_tokens=self._max_tokens
736
+ self._messages,
737
+ self._q,
738
+ max_tokens=self._max_tokens,
739
+ stats=self._stats,
717
740
  )
718
741
 
719
742
  def _cancel_task(self) -> None:
@@ -766,6 +789,28 @@ class _StreamIterator:
766
789
  self._got_first = True
767
790
  return str(item)
768
791
 
792
+ # ---------- 收尾统计(只读) ----------
793
+
794
+ @property
795
+ def reasoning_chars(self) -> int:
796
+ """推理(思考)内容字符数;非思考模型恒为 0。"""
797
+ return self._stats.reasoning_chars
798
+
799
+ @property
800
+ def content_chars(self) -> int:
801
+ """已产出正文字符数(与本迭代器产出的字符数严格相等)。"""
802
+ return self._stats.content_chars
803
+
804
+ @property
805
+ def finish_reason(self) -> str | None:
806
+ """结束原因:``stop`` / ``length`` / ``content_filter`` / …;未给出为 None。"""
807
+ return self._stats.finish_reason
808
+
809
+ @property
810
+ def truncated(self) -> bool:
811
+ """是否因输出长度上限被截断(``finish_reason`` 为 length / max_tokens)。"""
812
+ return self._stats.truncated
813
+
769
814
 
770
815
  async def _delayed_close(services: list[Service]) -> None:
771
816
  """延迟回收旧 service:给在途调用留宽限期,到期统一关闭(须在 loop 内)。"""
@@ -1,160 +0,0 @@
1
- """
2
- Copyright (c) 2021-2026 Clark Chang. All Rights Reserved.
3
-
4
- Licensed under the Apache License, Version 2.0 (the "License");
5
- you may not use this file except in compliance with the License.
6
- You may obtain a copy of the License at
7
-
8
- http://www.apache.org/licenses/LICENSE-2.0
9
-
10
- Unless required by applicable law or agreed to in writing, software
11
- distributed under the License is distributed on an "AS IS" BASIS,
12
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
- See the License for the specific language governing permissions and
14
- limitations under the License.
15
-
16
- Project: sdpy
17
- Author: Clark Chang
18
- """
19
-
20
- import json
21
- import time
22
- from collections.abc import AsyncIterator
23
- from dataclasses import dataclass
24
- from typing import ClassVar, cast, override
25
-
26
- from kit.ai.base_service import (
27
- _LOG_PAYLOAD_LIMIT,
28
- BaseAIService,
29
- _clip,
30
- _truncate,
31
- safe_get,
32
- )
33
- from kit.ai.exceptions import AIError, AIResponseError
34
- from kit.ai.types import JSONObject
35
- from kit.config.log_config import get_default_logger
36
-
37
- __all__ = ["ChatRequest", "ChatService"]
38
-
39
- logger = get_default_logger(__name__)
40
-
41
- Message = dict[str, str]
42
-
43
-
44
- @dataclass(frozen=True)
45
- class ChatRequest:
46
- """对话请求模型。"""
47
-
48
- messages: list[Message]
49
- temperature: float | None = None
50
- max_tokens: int | None = None
51
- stream: bool = False
52
-
53
-
54
- class ChatService(BaseAIService[ChatRequest, str]):
55
- """对话类模型服务(全异步)。"""
56
-
57
- _LABEL: ClassVar[str] = "对话模型"
58
-
59
- @override
60
- def _build_payload(self, request: ChatRequest) -> JSONObject:
61
- payload: dict[str, object] = {
62
- "model": self._profile.model,
63
- "messages": request.messages,
64
- "stream": request.stream,
65
- }
66
- if request.temperature is not None:
67
- payload["temperature"] = request.temperature
68
- # max_tokens 逐级回落:调用点显式传入 → 档案配置 → 不写(provider 默认)
69
- max_tokens = (
70
- request.max_tokens
71
- if request.max_tokens is not None
72
- else self._profile.max_tokens
73
- )
74
- if max_tokens is not None:
75
- payload["max_tokens"] = max_tokens
76
- return cast(JSONObject, payload)
77
-
78
- @override
79
- def _parse_response(self, data: JSONObject) -> str:
80
- content = safe_get(data, "choices", 0, "message", "content")
81
- if not isinstance(content, str):
82
- raise AIResponseError(f"content 类型异常: {type(content).__name__}")
83
- return content
84
-
85
- async def ask(
86
- self,
87
- messages: list[Message],
88
- *,
89
- temperature: float | None = None,
90
- max_tokens: int | None = None,
91
- deadline: float | None = None,
92
- ) -> str:
93
- """多轮对话,返回模型回答文本。
94
-
95
- Args:
96
- deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
97
- 5xx 重试预算;None 表示不限制
98
- """
99
- request = ChatRequest(messages, temperature=temperature, max_tokens=max_tokens)
100
- return await self._call(request, deadline=deadline)
101
-
102
- async def complete(
103
- self,
104
- prompt: str,
105
- *,
106
- temperature: float | None = None,
107
- max_tokens: int | None = None,
108
- deadline: float | None = None,
109
- ) -> str:
110
- messages: list[Message] = [{"role": "user", "content": prompt}]
111
- return await self.ask(
112
- messages, temperature=temperature, max_tokens=max_tokens, deadline=deadline
113
- )
114
-
115
- async def stream(
116
- self,
117
- messages: list[Message],
118
- *,
119
- temperature: float | None = None,
120
- max_tokens: int | None = None,
121
- ) -> AsyncIterator[str]:
122
- request = ChatRequest(
123
- messages, temperature=temperature, max_tokens=max_tokens, stream=True
124
- )
125
- payload = self._build_payload(request)
126
- desc = self._log_desc()
127
- params = _truncate(
128
- json.dumps(_clip(payload), ensure_ascii=False), _LOG_PAYLOAD_LIMIT
129
- )
130
- # 惰性 Start:异步生成器体在首次 __anext__ 前不执行,不消费不打日志
131
- logger.info(f"********** {desc} ********** Start")
132
- logger.info(f"请求参数: {params}")
133
- start = time.perf_counter()
134
- note = ""
135
- failed = False
136
- try:
137
- async for parsed in self._client.stream_json(payload):
138
- if parsed is None:
139
- continue
140
- try:
141
- delta = safe_get(parsed, "choices", 0, "delta", "content")
142
- except AIResponseError:
143
- # 流式结束帧等无 delta 的正常帧,跳过
144
- logger.debug("流式帧缺少 delta.content,跳过")
145
- continue
146
- if isinstance(delta, str) and delta:
147
- yield delta
148
- except AIError as exc:
149
- note = f"(失败:{exc})"
150
- failed = True
151
- raise
152
- except BaseException: # noqa: BLE001 - 中断路径(提前 break/取消)也需收尾日志
153
- note = "(中断)"
154
- raise
155
- finally:
156
- log = logger.error if failed else logger.info
157
- log(
158
- f"********** {desc} ********** "
159
- f"耗时 {time.perf_counter() - start:.1f}秒{note}"
160
- )
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