sdpy-kit 0.7.1__tar.gz → 0.7.3__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 (58) hide show
  1. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/PKG-INFO +19 -13
  2. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/README.md +18 -12
  3. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/chat_service.py +42 -29
  5. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/profiles.py +9 -0
  6. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/runner.py +32 -4
  7. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/sdk_client.py +2 -4
  8. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/.gitignore +0 -0
  9. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/__init__.py +0 -0
  10. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/_retry_after.py +0 -0
  11. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/_service_base.py +0 -0
  12. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/embedding_service.py +0 -0
  13. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/exceptions.py +0 -0
  14. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/failover.py +0 -0
  15. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/image_service.py +0 -0
  16. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/model_builder.py +0 -0
  17. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/model_client.py +0 -0
  18. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/pool.py +0 -0
  19. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/rerank_service.py +0 -0
  20. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/rerank_transport.py +0 -0
  21. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/ai/types.py +0 -0
  22. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/common_kit.py +0 -0
  23. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/config/__init__.py +0 -0
  24. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/config/kit_config.py +0 -0
  25. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/config/log_config.py +0 -0
  26. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/crawl/__init__.py +0 -0
  27. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/crawl/article_io.py +0 -0
  28. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/crawl/article_models.py +0 -0
  29. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/crawl/deepseek_kit.py +0 -0
  30. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/crawl/web_article_kit.py +0 -0
  31. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/crawl/wx_article_kit.py +0 -0
  32. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/__init__.py +0 -0
  33. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/audit.py +0 -0
  34. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/base_db_kit.py +0 -0
  35. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/crud.py +0 -0
  36. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/engine.py +0 -0
  37. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/exceptions.py +0 -0
  38. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/models/__init__.py +0 -0
  39. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/models/base.py +0 -0
  40. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/mysql_kit.py +0 -0
  41. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/pg_kit.py +0 -0
  42. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/query.py +0 -0
  43. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/session.py +0 -0
  44. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/sqlite_kit.py +0 -0
  45. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/db/transaction.py +0 -0
  46. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/decima_kit.py +0 -0
  47. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/docs/__init__.py +0 -0
  48. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/docs/anydoc_kit.py +0 -0
  49. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/docs/docx_kit.py +0 -0
  50. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/docs/marker_kit.py +0 -0
  51. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/docs/md2doc_kit.py +0 -0
  52. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/docs/mineru_kit.py +0 -0
  53. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/docs/rc_docparser_kit.py +0 -0
  54. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/file_kit.py +0 -0
  55. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/laya_kit.py +0 -0
  56. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/license_kit.py +0 -0
  57. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/kit/redis_kit.py +0 -0
  58. {sdpy_kit-0.7.1 → sdpy_kit-0.7.3}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.7.1
3
+ Version: 0.7.3
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -835,7 +835,7 @@ shutdown() # 应用退出前
835
835
  |------|------|------|
836
836
  | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 单轮对话(仅 user 消息) |
837
837
  | chat | `chat_ask(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
838
- | chat | `chat_stream(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied` |
838
+ | chat | `chat_stream(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `requested_max_tokens` / `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied` |
839
839
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
840
840
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
841
841
  | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表;**`top_n` 是返回条数上限,由 kit 本地裁剪**(服务端返回超量也按分数截断),`top_n=None` 返回全部命中 |
@@ -869,39 +869,45 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
869
869
 
870
870
  #### 流式收尾统计(思考型模型必备)
871
871
 
872
- 迭代器在流结束后可读七个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
873
- `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
874
- token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」(同一套字段也可用于非流式 `ask` / `complete`,
875
- 见下文「结构化输出」)。
872
+ 迭代器在流结束后可读八个字段(`reasoning_chars` / `content_chars` / `finish_reason` /
873
+ `requested_max_tokens` / `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied`),
874
+ 其中 `truncated` 与 `empty_kind` 是只读派生属性。用于区分「模型真的没返回」与「输出预算被思考吃光」——
875
+ 思考型模型的推理 token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」
876
+ (同一套字段也可用于非流式 `ask` / `complete`,见下文「结构化输出」)。
876
877
 
877
- `empty_kind` 把「正文为空」分成四类,不再一律归因为预算耗尽:
878
+ `empty_kind` 把「正文为空」分成五类,不再一律归因为预算耗尽:
878
879
 
879
880
  | 取值 | 判定 | 建议处置 |
880
881
  |---|---|---|
881
882
  | `budget_exhausted` | 被长度截断且有推理 | 调大 `max_tokens`,或对该调用传 `thinking=False` |
883
+ | `truncated_empty` | 被长度截断且无推理 | 调大 `max_tokens`;若模型支持独立推理预算,确认推理是否计入输出配额 |
882
884
  | `reasoning_only` | 有推理、`finish_reason=stop`、正文 0 | 提高预算或换模型 |
883
- | `no_finish` | 有推理但没收到结束原因(链路中断 / 端点未收尾) | 优先重试,连续出现检查端点/代理超时 |
885
+ | `no_finish` | 结束原因缺失或非 `stop`(端点未发送 `finish_reason`) | 可原样重试;连续出现检查端点 |
884
886
  | `stopped_empty` | 无推理、`finish_reason=stop`、正文 0 | 检查提示词与模型输出 |
885
887
 
886
- 正文非空时 `empty_kind is None`。判定只用「是否有推理 + 结束原因」,不依赖 token 数(kit 目前不采集 `usage`):
888
+ 正文非空时 `empty_kind is None`。判定只用「是否截断 + 是否有推理 + 结束原因」,不依赖 token 数(kit 目前不采集 `usage`)。
889
+ 注意:该属性**仅在流正常结束后可读**,迭代中与异常 / 中断后为 `None`(避免把「正文还没到」误判成 `no_finish`);`requested_max_tokens` 无此限制。
887
890
 
888
891
  ```python
889
892
  with AI.chat_stream(messages, max_tokens=450) as stream:
890
893
  body = "".join(stream)
891
894
  if not body:
892
895
  match stream.empty_kind:
893
- case "budget_exhausted":
894
- ... # 调大 max_tokens 或 thinking=False
896
+ case "budget_exhausted" | "truncated_empty":
897
+ ... # 调大 max_tokens(或配独立推理预算)
895
898
  case "no_finish":
896
- ... # 优先重试
899
+ ... # 原样重试
897
900
  ```
898
901
 
902
+ `requested_max_tokens` 是本次**实际下发**的输出上限(调用方传值优先,否则取档案 `max_tokens`;都没有为 `None`),
903
+ 在发请求前写入,失败/中断时也可读——业务可据此判断「预算是否真的设够了」。
904
+
899
905
  - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
900
906
  - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` / `empty_kind` 判定;
901
907
  - 正文为空时 kit 按 `empty_kind` 分级打 `WARNING`(含模型、`max_tokens`、`finish_reason` 与推荐处置),不再统一归因「预算被思考耗尽」;
902
908
  - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
903
909
  - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
904
- - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
910
+ - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;正文为空则归为 `no_finish`(可原样重试);
905
911
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
906
912
 
907
913
  #### 逐调用思考开关
@@ -753,7 +753,7 @@ shutdown() # 应用退出前
753
753
  |------|------|------|
754
754
  | chat | `chat_complete(prompt, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 单轮对话(仅 user 消息) |
755
755
  | chat | `chat_ask(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None, stats=None) -> str` | 多轮对话,`messages` 为 `[{"role": "user", "content": "..."}]` |
756
- | chat | `chat_stream(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied` |
756
+ | chat | `chat_stream(messages, *, max_tokens=None, thinking=None, json_mode=None, response_format=None) -> Iterator[str]` | 同步流式逐 token 迭代器(建议 `with` 使用);结束后可读 `reasoning_chars` / `content_chars` / `finish_reason` / `requested_max_tokens` / `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied` |
757
757
  | embedding | `embed_text(text) -> list[float]` | 单文本向量化 |
758
758
  | embedding | `embed_texts(texts) -> list[list[float]]` | 批量向量化 |
759
759
  | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表;**`top_n` 是返回条数上限,由 kit 本地裁剪**(服务端返回超量也按分数截断),`top_n=None` 返回全部命中 |
@@ -787,39 +787,45 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
787
787
 
788
788
  #### 流式收尾统计(思考型模型必备)
789
789
 
790
- 迭代器在流结束后可读七个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
791
- `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
792
- token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」(同一套字段也可用于非流式 `ask` / `complete`,
793
- 见下文「结构化输出」)。
790
+ 迭代器在流结束后可读八个字段(`reasoning_chars` / `content_chars` / `finish_reason` /
791
+ `requested_max_tokens` / `truncated` / `empty_kind` / `thinking_applied` / `json_mode_applied`),
792
+ 其中 `truncated` 与 `empty_kind` 是只读派生属性。用于区分「模型真的没返回」与「输出预算被思考吃光」——
793
+ 思考型模型的推理 token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」
794
+ (同一套字段也可用于非流式 `ask` / `complete`,见下文「结构化输出」)。
794
795
 
795
- `empty_kind` 把「正文为空」分成四类,不再一律归因为预算耗尽:
796
+ `empty_kind` 把「正文为空」分成五类,不再一律归因为预算耗尽:
796
797
 
797
798
  | 取值 | 判定 | 建议处置 |
798
799
  |---|---|---|
799
800
  | `budget_exhausted` | 被长度截断且有推理 | 调大 `max_tokens`,或对该调用传 `thinking=False` |
801
+ | `truncated_empty` | 被长度截断且无推理 | 调大 `max_tokens`;若模型支持独立推理预算,确认推理是否计入输出配额 |
800
802
  | `reasoning_only` | 有推理、`finish_reason=stop`、正文 0 | 提高预算或换模型 |
801
- | `no_finish` | 有推理但没收到结束原因(链路中断 / 端点未收尾) | 优先重试,连续出现检查端点/代理超时 |
803
+ | `no_finish` | 结束原因缺失或非 `stop`(端点未发送 `finish_reason`) | 可原样重试;连续出现检查端点 |
802
804
  | `stopped_empty` | 无推理、`finish_reason=stop`、正文 0 | 检查提示词与模型输出 |
803
805
 
804
- 正文非空时 `empty_kind is None`。判定只用「是否有推理 + 结束原因」,不依赖 token 数(kit 目前不采集 `usage`):
806
+ 正文非空时 `empty_kind is None`。判定只用「是否截断 + 是否有推理 + 结束原因」,不依赖 token 数(kit 目前不采集 `usage`)。
807
+ 注意:该属性**仅在流正常结束后可读**,迭代中与异常 / 中断后为 `None`(避免把「正文还没到」误判成 `no_finish`);`requested_max_tokens` 无此限制。
805
808
 
806
809
  ```python
807
810
  with AI.chat_stream(messages, max_tokens=450) as stream:
808
811
  body = "".join(stream)
809
812
  if not body:
810
813
  match stream.empty_kind:
811
- case "budget_exhausted":
812
- ... # 调大 max_tokens 或 thinking=False
814
+ case "budget_exhausted" | "truncated_empty":
815
+ ... # 调大 max_tokens(或配独立推理预算)
813
816
  case "no_finish":
814
- ... # 优先重试
817
+ ... # 原样重试
815
818
  ```
816
819
 
820
+ `requested_max_tokens` 是本次**实际下发**的输出上限(调用方传值优先,否则取档案 `max_tokens`;都没有为 `None`),
821
+ 在发请求前写入,失败/中断时也可读——业务可据此判断「预算是否真的设够了」。
822
+
817
823
  - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
818
824
  - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` / `empty_kind` 判定;
819
825
  - 正文为空时 kit 按 `empty_kind` 分级打 `WARNING`(含模型、`max_tokens`、`finish_reason` 与推荐处置),不再统一归因「预算被思考耗尽」;
820
826
  - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
821
827
  - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
822
- - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
828
+ - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;正文为空则归为 `no_finish`(可原样重试);
823
829
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
824
830
 
825
831
  #### 逐调用思考开关
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.7.1"
26
+ __version__ = "0.7.3"
27
27
 
28
28
  from kit.db import (
29
29
  MYSQL,
@@ -29,7 +29,11 @@ from dataclasses import dataclass
29
29
  from typing import Any, ClassVar, Final, cast
30
30
 
31
31
  from kit.ai.exceptions import AIError, AIResponseError
32
- from kit.ai.profiles import _RESERVED_PAYLOAD_KEYS, ModelProfile
32
+ from kit.ai.profiles import (
33
+ _RESERVED_PAYLOAD_KEYS,
34
+ ModelProfile,
35
+ resolve_max_tokens,
36
+ )
33
37
  from kit.ai.sdk_client import SdkServiceBase
34
38
  from kit.ai.types import JSONObject
35
39
  from kit.config.log_config import get_default_logger
@@ -50,15 +54,17 @@ _TRUNCATED_REASONS: Final[frozenset[str]] = frozenset({"length", "max_tokens"})
50
54
  # 空正文诊断分级 → 告警文案(正文为 0 时才有分级,见 _classify_empty)
51
55
  _EMPTY_HINTS: Final[dict[str, str]] = {
52
56
  "budget_exhausted": "输出预算疑被思考耗尽(长度截断)",
57
+ "truncated_empty": "输出被长度截断、正文为空(无推理)",
53
58
  "reasoning_only": "只产出推理、正文为空(finish=stop)",
54
- "no_finish": "只产出推理、未收到结束原因(链路中断或端点未收尾)",
59
+ "no_finish": "正文为空且未收到正常结束原因",
55
60
  "stopped_empty": "无正文输出(finish=stop)",
56
61
  }
57
62
 
58
63
  _EMPTY_ACTIONS: Final[dict[str, str]] = {
59
64
  "budget_exhausted": "(可调大 max_tokens,或对该调用传 thinking=False 关闭思考)",
65
+ "truncated_empty": "(调大 max_tokens;若模型支持独立推理预算,确认推理是否计入输出配额)",
60
66
  "reasoning_only": "(推理未触顶但正文为空,可提高预算或换模型)",
61
- "no_finish": "(优先重试;连续出现请检查端点/代理超时)",
67
+ "no_finish": "(端点未发送 finish_reason 或异常结束,可原样重试)",
62
68
  "stopped_empty": "(检查提示词与模型输出)",
63
69
  }
64
70
 
@@ -70,20 +76,24 @@ def _classify_empty(
70
76
  finish_reason: str | None,
71
77
  truncated: bool,
72
78
  ) -> str | None:
73
- """空正文诊断分级;正文非空或无法归因时返回 None。
79
+ """空正文诊断分级;正文非空返回 None。
74
80
 
75
81
  - ``budget_exhausted``:被截断且有推理(预算被思考吃满)
82
+ - ``truncated_empty``:被截断且无推理(一个正文字符都没出)
76
83
  - ``reasoning_only``:有推理、``finish_reason=stop``
77
- - ``no_finish``:有推理但结束原因缺失或非 stop
84
+ - ``no_finish``:结束原因缺失或非 stop(无论有无推理)
78
85
  - ``stopped_empty``:无推理、``finish_reason=stop``
79
86
  """
80
87
  if content_chars:
81
88
  return None
82
- if truncated and reasoning_chars:
83
- return "budget_exhausted"
89
+ if truncated:
90
+ return "budget_exhausted" if reasoning_chars else "truncated_empty"
84
91
  if reasoning_chars:
85
92
  return "reasoning_only" if finish_reason == "stop" else "no_finish"
86
- return "stopped_empty" if finish_reason == "stop" else None
93
+ if finish_reason == "stop":
94
+ return "stopped_empty"
95
+ return "no_finish"
96
+
87
97
 
88
98
  # 「无注入值」哨兵:区分「不注入」与「注入值为 None」
89
99
  _UNSET: Final[object] = object()
@@ -172,10 +182,13 @@ class StreamStats:
172
182
  True 已按档案声明注入 / False 要求了但档案完全没有 thinking 声明
173
183
  json_mode_applied: 结构化输出是否生效——None 本次未要求注入 /
174
184
  True 已注入 / False 要求了但档案声明不支持(已忽略)
185
+ requested_max_tokens: 本次实际下发的输出上限(调用方 → 档案回落);
186
+ None 表示未下发、由 provider 默认值决定。在发出请求前写入,
187
+ 失败/中断时也可读
175
188
 
176
189
  只读派生属性:``truncated``(是否截断)、``empty_kind``(正文为空时的
177
- 诊断分级:``budget_exhausted`` / ``reasoning_only`` / ``no_finish`` /
178
- ``stopped_empty``;正文非空为 None)。
190
+ 诊断分级:``budget_exhausted`` / ``truncated_empty`` / ``reasoning_only`` /
191
+ ``no_finish`` / ``stopped_empty``;正文非空为 None)。
179
192
  """
180
193
 
181
194
  reasoning_chars: int = 0
@@ -183,6 +196,7 @@ class StreamStats:
183
196
  finish_reason: str | None = None
184
197
  thinking_applied: bool | None = None
185
198
  json_mode_applied: bool | None = None
199
+ requested_max_tokens: int | None = None
186
200
 
187
201
  @property
188
202
  def truncated(self) -> bool:
@@ -193,9 +207,10 @@ class StreamStats:
193
207
  def empty_kind(self) -> str | None:
194
208
  """正文为空时的诊断分级;正文非空返回 None。
195
209
 
196
- 取值:``budget_exhausted`` / ``reasoning_only`` / ``no_finish`` /
197
- ``stopped_empty``。不依赖 token 数量,用「是否有推理 + 结束原因」
198
- 区分「预算吃满」「只推理」「链路未收尾」「正常停住」。
210
+ 取值:``budget_exhausted`` / ``truncated_empty`` / ``reasoning_only`` /
211
+ ``no_finish`` / ``stopped_empty``。不依赖 token 数量,用「是否截断 +
212
+ 是否有推理 + 结束原因」区分「预算吃满」「截断且无正文」「只推理」
213
+ 「未正常收尾」「正常停住」。
199
214
  """
200
215
  return _classify_empty(
201
216
  content_chars=self.content_chars,
@@ -211,6 +226,7 @@ class StreamStats:
211
226
  self.finish_reason = None
212
227
  self.thinking_applied = None
213
228
  self.json_mode_applied = None
229
+ self.requested_max_tokens = None
214
230
 
215
231
 
216
232
  @dataclass(frozen=True)
@@ -383,7 +399,8 @@ class ChatService(SdkServiceBase):
383
399
  response_format: 协议级原样透传(含 ``json_schema`` 严格模式);
384
400
  与 ``json_mode`` 互斥,schema 由调用方负责
385
401
  stats: 收尾统计累加器(可选);传入后可读 ``finish_reason`` /
386
- ``truncated`` / ``reasoning_chars`` / ``json_mode_applied``
402
+ ``truncated`` / ``empty_kind`` / ``requested_max_tokens`` /
403
+ ``reasoning_chars`` / ``json_mode_applied``
387
404
  deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
388
405
  5xx 重试预算;None 表示不限制
389
406
  """
@@ -397,6 +414,9 @@ class ChatService(SdkServiceBase):
397
414
  stats=stats,
398
415
  )
399
416
  extra_body, response_format_arg = self._build_injection(request)
417
+ effective_max_tokens = resolve_max_tokens(self._profile, request.max_tokens)
418
+ if stats is not None:
419
+ stats.requested_max_tokens = effective_max_tokens
400
420
  self._debug_payload(
401
421
  {
402
422
  "model": self._profile.model,
@@ -434,11 +454,6 @@ class ChatService(SdkServiceBase):
434
454
  finish_reason=finish,
435
455
  truncated=truncated,
436
456
  )
437
- effective_max_tokens = (
438
- request.max_tokens
439
- if request.max_tokens is not None
440
- else self._profile.max_tokens
441
- )
442
457
  if kind is not None:
443
458
  self._log_empty(
444
459
  kind=kind,
@@ -505,11 +520,12 @@ class ChatService(SdkServiceBase):
505
520
  kit 不做增量解析
506
521
  response_format: 协议级原样透传(与 ``json_mode`` 互斥)
507
522
  stats: 收尾统计累加器(可选);None 时内部创建后丢弃——需要
508
- ``reasoning_chars`` / ``finish_reason`` / ``truncated`` 时
509
- 由调用方传入一个实例
523
+ ``reasoning_chars`` / ``finish_reason`` / ``truncated`` /
524
+ ``empty_kind`` / ``requested_max_tokens`` 时由调用方传入
510
525
 
511
526
  推理内容(``delta.reasoning_content`` / ``delta.reasoning``)只计数
512
- 不产出;正文为空且被截断(或发生过思考)时输出 warning。
527
+ 不产出;正文为空时按 ``empty_kind`` 分级输出 warning(不依赖是否有
528
+ 推理或是否截断)。
513
529
  """
514
530
  stats = stats if stats is not None else StreamStats()
515
531
  request = ChatRequest(
@@ -523,6 +539,8 @@ class ChatService(SdkServiceBase):
523
539
  stats=stats,
524
540
  )
525
541
  extra_body, response_format_arg = self._build_injection(request)
542
+ effective_max_tokens = resolve_max_tokens(self._profile, request.max_tokens)
543
+ stats.requested_max_tokens = effective_max_tokens
526
544
  desc = self._log_desc()
527
545
  # 惰性 Start:异步生成器体在首次 __anext__ 前不执行,不消费不打日志
528
546
  logger.info(f"********** {desc} ********** Start")
@@ -561,11 +579,6 @@ class ChatService(SdkServiceBase):
561
579
  note = "(中断)"
562
580
  raise
563
581
  else:
564
- effective_max_tokens = (
565
- request.max_tokens
566
- if request.max_tokens is not None
567
- else self._profile.max_tokens
568
- )
569
582
  self._warn_if_empty(stats, effective_max_tokens)
570
583
  finally:
571
584
  log = logger.error if failed else logger.info
@@ -574,7 +587,7 @@ class ChatService(SdkServiceBase):
574
587
  f"耗时 {time.perf_counter() - start:.1f}秒{note}"
575
588
  )
576
589
 
577
- def _warn_if_empty(self, stats: StreamStats, max_tokens: object) -> None:
590
+ def _warn_if_empty(self, stats: StreamStats, max_tokens: int | None) -> None:
578
591
  """正文为空时按诊断分级告警(不再一律归因为「预算被思考耗尽」)。"""
579
592
  kind = stats.empty_kind
580
593
  if kind is None:
@@ -590,7 +603,7 @@ class ChatService(SdkServiceBase):
590
603
  self,
591
604
  *,
592
605
  kind: str,
593
- max_tokens: object,
606
+ max_tokens: int | None,
594
607
  finish: str | None,
595
608
  reasoning_chars: int,
596
609
  ) -> None:
@@ -536,6 +536,15 @@ def with_api_key(profile: ModelProfile, api_key: str) -> ModelProfile:
536
536
  return replace(profile, api_key=api_key)
537
537
 
538
538
 
539
+ def resolve_max_tokens(profile: ModelProfile, max_tokens: int | None) -> int | None:
540
+ """解析本次实际下发的输出上限:调用方 → 档案 → ``None``(provider 默认)。
541
+
542
+ 单一数据源:``ChatService``(写入 ``StreamStats`` 与日志)与
543
+ ``SdkClient._chat_params``(构造请求体)共用,避免两处回落逻辑漂移。
544
+ """
545
+ return max_tokens if max_tokens is not None else profile.max_tokens
546
+
547
+
539
548
  @lru_cache(maxsize=1)
540
549
  def get_model_registry() -> ModelRegistry:
541
550
  """获取全局单例 ModelRegistry(首次调用自动 load 默认档案文件)。"""
@@ -846,10 +846,14 @@ class _StreamIterator:
846
846
  直接 ``for`` 中途 ``break`` 或迭代器被 GC,``close``/``__del__`` 也会
847
847
  兜底取消后台 producer,避免任务残留。
848
848
 
849
- 流结束后可读 ``reasoning_chars`` / ``content_chars`` / ``finish_reason`` /
850
- ``truncated`` / ``thinking_applied`` / ``json_mode_applied`` 六个只读属性
851
- (跨线程共享同一累加器:结束后为最终值,流式过程中为尽力而为的中间值;
852
- 提前 ``break`` 时为截至中断的累计值)。
849
+ 流结束后可读八个只读属性:``reasoning_chars`` / ``content_chars`` /
850
+ ``finish_reason`` / ``requested_max_tokens`` / ``truncated`` / ``empty_kind`` /
851
+ ``thinking_applied`` / ``json_mode_applied``(跨线程共享同一累加器:
852
+ 结束后为最终值,流式过程中为尽力而为的中间值;提前 ``break`` 时为截至
853
+ 中断的累计值)。
854
+
855
+ ``empty_kind`` 例外:**仅在流正常结束后可读**,迭代中与异常 / 中断后
856
+ 恒为 ``None``(避免把「正文还没到」误判成 ``no_finish``)。
853
857
  """
854
858
 
855
859
  def __init__(
@@ -871,6 +875,8 @@ class _StreamIterator:
871
875
  self._started = False
872
876
  self._closed = False
873
877
  self._got_first = False
878
+ # 正常收尾(收到哨兵)才置真:empty_kind 只在结束后给结论
879
+ self._exhausted = False
874
880
  self._task: concurrent.futures.Future[None] | None = None
875
881
 
876
882
  def __iter__(self) -> Iterator[str]:
@@ -931,6 +937,7 @@ class _StreamIterator:
931
937
  f"流式帧间隔超过 {_STREAM_FRAME_INTERVAL_TIMEOUT:g}s 未收到新帧"
932
938
  ) from exc
933
939
  if item is _SENTINEL:
940
+ self._exhausted = True
934
941
  self.close()
935
942
  raise StopIteration
936
943
  if isinstance(item, BaseException):
@@ -971,6 +978,27 @@ class _StreamIterator:
971
978
  """结构化输出是否生效:None 未要求注入 / True 已注入 / False 被忽略。"""
972
979
  return self._stats.json_mode_applied
973
980
 
981
+ @property
982
+ def requested_max_tokens(self) -> int | None:
983
+ """本次实际下发的输出上限(调用方 → 档案回落);未发请求为 None。"""
984
+ return self._stats.requested_max_tokens
985
+
986
+ @property
987
+ def empty_kind(self) -> str | None:
988
+ """正文为空时的诊断分级;**仅在流正常结束后可读,其余时刻为 None**。
989
+
990
+ 取值同 ``StreamStats.empty_kind``(``budget_exhausted`` /
991
+ ``truncated_empty`` / ``reasoning_only`` / ``no_finish`` /
992
+ ``stopped_empty``;正文非空为 None)。
993
+
994
+ 迭代中不给中间结论:此时正文尚未产出,``content_chars`` 恒为 0,
995
+ 直接转发会把「还没出正文」误报成 ``no_finish``;异常 / 中途 ``break``
996
+ 同理不置收尾标志。
997
+ """
998
+ if not self._exhausted:
999
+ return None
1000
+ return self._stats.empty_kind
1001
+
974
1002
 
975
1003
  async def _delayed_close(services: list[Service]) -> None:
976
1004
  """延迟回收旧 service:给在途调用留宽限期,到期统一关闭(须在 loop 内)。"""
@@ -54,7 +54,7 @@ from kit.ai.exceptions import (
54
54
  AIResponseError,
55
55
  AITimeoutError,
56
56
  )
57
- from kit.ai.profiles import ModelProfile
57
+ from kit.ai.profiles import ModelProfile, resolve_max_tokens
58
58
  from kit.ai.types import JSONObject
59
59
  from kit.config.log_config import get_default_logger
60
60
 
@@ -193,9 +193,7 @@ class SdkClient:
193
193
  }
194
194
  if temperature is not None:
195
195
  params["temperature"] = temperature
196
- effective_max_tokens = (
197
- max_tokens if max_tokens is not None else self._profile.max_tokens
198
- )
196
+ effective_max_tokens = resolve_max_tokens(self._profile, max_tokens)
199
197
  if effective_max_tokens is not None:
200
198
  params["max_tokens"] = effective_max_tokens
201
199
  if response_format is not None:
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