sdpy-kit 0.7.0__tar.gz → 0.7.2__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.0 → sdpy_kit-0.7.2}/PKG-INFO +62 -30
  2. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/README.md +61 -29
  3. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/__init__.py +1 -1
  4. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/chat_service.py +133 -21
  5. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/exceptions.py +10 -0
  6. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/model_builder.py +7 -5
  7. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/model_client.py +5 -22
  8. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/pool.py +16 -3
  9. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/profiles.py +38 -12
  10. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/rerank_service.py +7 -1
  11. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/rerank_transport.py +1 -1
  12. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/runner.py +19 -9
  13. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/sdk_client.py +2 -4
  14. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/md2doc_kit.py +1 -10
  15. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/.gitignore +0 -0
  16. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/__init__.py +0 -0
  17. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/_retry_after.py +0 -0
  18. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/_service_base.py +0 -0
  19. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/embedding_service.py +0 -0
  20. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/failover.py +0 -0
  21. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/image_service.py +0 -0
  22. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/types.py +0 -0
  23. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/common_kit.py +0 -0
  24. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/config/__init__.py +0 -0
  25. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/config/kit_config.py +0 -0
  26. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/config/log_config.py +0 -0
  27. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/__init__.py +0 -0
  28. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/article_io.py +0 -0
  29. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/article_models.py +0 -0
  30. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/deepseek_kit.py +0 -0
  31. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/web_article_kit.py +0 -0
  32. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/wx_article_kit.py +0 -0
  33. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/__init__.py +0 -0
  34. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/audit.py +0 -0
  35. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/base_db_kit.py +0 -0
  36. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/crud.py +0 -0
  37. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/engine.py +0 -0
  38. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/exceptions.py +0 -0
  39. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/models/__init__.py +0 -0
  40. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/models/base.py +0 -0
  41. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/mysql_kit.py +0 -0
  42. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/pg_kit.py +0 -0
  43. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/query.py +0 -0
  44. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/session.py +0 -0
  45. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/sqlite_kit.py +0 -0
  46. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/transaction.py +0 -0
  47. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/decima_kit.py +0 -0
  48. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/__init__.py +0 -0
  49. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/anydoc_kit.py +0 -0
  50. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/docx_kit.py +0 -0
  51. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/marker_kit.py +0 -0
  52. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/mineru_kit.py +0 -0
  53. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/rc_docparser_kit.py +0 -0
  54. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/file_kit.py +0 -0
  55. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/laya_kit.py +0 -0
  56. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/license_kit.py +0 -0
  57. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/redis_kit.py +0 -0
  58. {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sdpy-kit
3
- Version: 0.7.0
3
+ Version: 0.7.2
4
4
  Summary: SDPY Kit——统一数据库访问层(PG/MySQL/SQLite)与 AI/Redis/文件/文档/向量工具集
5
5
  Author-email: Clark <changhongyuan@126.com>
6
6
  License-Expression: MIT
@@ -86,7 +86,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
86
86
 
87
87
  - **统一数据库访问层**:PostgreSQL / MySQL / SQLite,三个 Kit API 完全一致,切换数据库只改一行 import;内置审计字段自动填充(`created_*` / `updated_*` / `deleted`)与软删除查询过滤;
88
88
  - **AI 多模型服务池**:chat / embedding / rerank / image 四类能力,多厂商候选链 + 故障自动转移;
89
- - **工具集**:Redis / 文件 / 雪花 ID / 离线授权签发与校验 / 文档互转 / Word 模板填充 / MinerU 解析 / Marker 本地解析(PDF + 图片 → md + images/) / 离线 docx 解析(md + images/) / Laya 类型化决策 / Decima 决策模型(选项乱序不变、置信度可门控) / 微信公众号文章抓取存储 / 通用网页文章抓取存储 / DeepSeek 搜索与抓取。
89
+ - **工具集**:Redis / 文件 / 雪花 ID / 离线授权签发与校验 / 文档互转 / Word 模板填充 / MinerU 解析 / Marker 本地解析(0.7.0 起暂停发布,需要请用 `sdpy-kit==0.6.10`) / 离线 docx 解析(md + images/) / Laya 类型化决策 / Decima 决策模型(选项乱序不变、置信度可门控) / 微信公众号文章抓取存储 / 通用网页文章抓取存储 / DeepSeek 搜索与抓取。
90
90
 
91
91
  > 要求 Python 3.13+
92
92
 
@@ -809,7 +809,7 @@ page = qb.paginate(page=1, per_page=20) # {"items", "total", ...}
809
809
 
810
810
  安装:`pip install "sdpy-kit[ai]"`
811
811
 
812
- 传输(0.6.11):chat / embedding / image 走 `openai` SDK(`>=3.44,<4`,
812
+ 传输:chat / embedding / image 走 `openai` SDK(`>=3.44,<4`,
813
813
  自带 `httpx2`),rerank 走 httpx 自研传输(SDK 无 `/rerank` 资源)。
814
814
  档案里的 `endpoint` **对前三类不再生效**——SDK 固定拼 `/chat/completions`、
815
815
  `/embeddings`、`/images/generations`,仅 rerank 与探活仍使用该字段。
@@ -835,14 +835,29 @@ 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` / `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
- | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表 |
841
+ | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表;**`top_n` 是返回条数上限,由 kit 本地裁剪**(服务端返回超量也按分数截断),`top_n=None` 返回全部命中 |
842
842
  | image | `generate_image(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> ImageResult` | 文生图,返回 `ImageResult`(`url` / `b64` 至少一个非空) |
843
843
  | 管理 | `use(model_type, profile) -> None` | 切换该类型当前档案(`chat` / `embedding` / `rerank` / `image`) |
844
- | 管理 | `available(model_type) -> list[ProfileStatus]` | 各档案状态快照(含未配置与被剔除者) |
845
- | 管理 | `reload_config(*, model_type=None, reload_file=False, probe=True, immediate=False) -> ReloadResult` | 配置热重载,改密钥 / 档案后无需重启 |
844
+ | 管理 | `available(model_type) -> list[ProfileStatus]` | 各档案状态快照(含未配置密钥与被剔除者);**类型未在 `ai_models.json` 中配置时返回 `[]`**(不再抛异常),类型名非法仍抛 `AIConfigError` |
845
+ | 管理 | `reload_config(*, model_type=None, reload_file=False, probe=True, immediate=False) -> ReloadResult` | 配置热重载,改密钥 / 档案后无需重启;同时清空思考/结构化输出的告警去重表(修正档案后同一原因会重新提示) |
846
+
847
+ **可用性查询(`available`)**:`available` 返回的是**状态快照**,不是「可用档案列表」。
848
+ 判断某类型是否已配置用 `if not AI.available(t)`,不要靠捕获异常:
849
+
850
+ ```python
851
+ from kit.ai.runner import AI
852
+
853
+ profiles = AI.available("image") # ai_models.json 无 image 段 → []
854
+ if not profiles:
855
+ raise RuntimeError("尚未配置图像生成模型")
856
+ current = next((p for p in profiles if p.current), None)
857
+ ```
858
+
859
+ > 旧写法 `try: AI.available(t) except AIConfigError: ...` 仍可用(异常分支不再触发);
860
+ > `AI.available(t)[0]` 在类型未配置时会抛 `IndexError`,请改用上面的判空写法。
846
861
 
847
862
  ```python
848
863
  from kit.ai.runner import AI
@@ -854,30 +869,47 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
854
869
 
855
870
  #### 流式收尾统计(思考型模型必备)
856
871
 
857
- 迭代器在流结束后可读六个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
858
- `truncated` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
859
- token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」(同一套字段也可用于非流式 `ask` / `complete`,
860
- 见下文「结构化输出」):
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`,见下文「结构化输出」)。
877
+
878
+ `empty_kind` 把「正文为空」分成五类,不再一律归因为预算耗尽:
879
+
880
+ | 取值 | 判定 | 建议处置 |
881
+ |---|---|---|
882
+ | `budget_exhausted` | 被长度截断且有推理 | 调大 `max_tokens`,或对该调用传 `thinking=False` |
883
+ | `truncated_empty` | 被长度截断且无推理 | 调大 `max_tokens`;若模型支持独立推理预算,确认推理是否计入输出配额 |
884
+ | `reasoning_only` | 有推理、`finish_reason=stop`、正文 0 | 提高预算或换模型 |
885
+ | `no_finish` | 结束原因缺失或非 `stop`(端点未发送 `finish_reason`) | 可原样重试;连续出现检查端点 |
886
+ | `stopped_empty` | 无推理、`finish_reason=stop`、正文 0 | 检查提示词与模型输出 |
887
+
888
+ 正文非空时 `empty_kind is None`。判定只用「是否截断 + 是否有推理 + 结束原因」,不依赖 token 数(kit 目前不采集 `usage`):
861
889
 
862
890
  ```python
863
891
  with AI.chat_stream(messages, max_tokens=450) as stream:
864
892
  body = "".join(stream)
865
- if not body and stream.truncated and stream.reasoning_chars:
866
- raise ValueError(
867
- f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
868
- "请调大 max_tokens 或对该调用传 thinking=False"
869
- )
893
+ if not body:
894
+ match stream.empty_kind:
895
+ case "budget_exhausted" | "truncated_empty":
896
+ ... # 调大 max_tokens(或配独立推理预算)
897
+ case "no_finish":
898
+ ... # 原样重试
870
899
  ```
871
900
 
901
+ `requested_max_tokens` 是本次**实际下发**的输出上限(调用方传值优先,否则取档案 `max_tokens`;都没有为 `None`),
902
+ 在发请求前写入,失败/中断时也可读——业务可据此判断「预算是否真的设够了」。
903
+
872
904
  - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
873
- - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` 判定是否被截断;
874
- - 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
905
+ - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` / `empty_kind` 判定;
906
+ - 正文为空时 kit 按 `empty_kind` 分级打 `WARNING`(含模型、`max_tokens`、`finish_reason` 与推荐处置),不再统一归因「预算被思考耗尽」;
875
907
  - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
876
908
  - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
877
- - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
909
+ - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;正文为空则归为 `no_finish`(可原样重试);
878
910
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
879
911
 
880
- #### 逐调用思考开关(0.6.7)
912
+ #### 逐调用思考开关
881
913
 
882
914
  同一档案下按调用点切换思考(入库抽取关、章节生成开),不必复制档案:
883
915
 
@@ -896,14 +928,14 @@ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明
896
928
 
897
929
  `thinking_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求干预 / `True` 已按声明注入 / `False` 要求了但被忽略。
898
930
 
899
- > 这是对 0.6.5「不新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」结论的**有意修订**:
900
- > 当时的否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
931
+ > 设计说明:曾否决「新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」,
932
+ > 该否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
901
933
  > 字段只发给声明了它的候选,未声明者不注入。思考**档位**(`low/medium/high`)仍然不做。
902
934
 
903
935
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
904
936
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
905
937
 
906
- #### 结构化输出(JSON 模式,0.6.8)
938
+ #### 结构化输出(JSON 模式)
907
939
 
908
940
  只要「合法 JSON 对象」用 `json_mode=True`;要约束字段用协议的 `response_format` 原样透传:
909
941
 
@@ -1228,7 +1260,7 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
1228
1260
 
1229
1261
  | 方法 | 说明 |
1230
1262
  |------|------|
1231
- | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1263
+ | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体") -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1232
1264
  | `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
1233
1265
  | `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
1234
1266
 
@@ -1611,13 +1643,13 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1611
1643
 
1612
1644
  | 层级 | 字段 | 说明 |
1613
1645
  |------|------|------|
1614
- | 档案级 | `endpoint` | 请求路径。**0.6.10 起仅 rerank 生效**:chat / embedding / image 由 openai SDK 固定拼路径,本字段被忽略(保留仅为兼容旧配置,不报错) |
1646
+ | 档案级 | `endpoint` | 请求路径。**仅 rerank 生效**:chat / embedding / image 由 openai SDK 固定拼路径,本字段被忽略(保留仅为兼容旧配置,不报错) |
1615
1647
  | 档案级 | `timeout` | 请求超时秒数(默认 60);SDK 侧为单次调用总超时 |
1616
1648
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1617
1649
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1618
1650
  | 档案级 | `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)` 生效 |
1619
- | 档案级 | `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 只做翻译,不含任何厂商名 |
1620
- | 档案级 | `structured_output` | 结构化输出的注入声明(0.6.8):`{"arg": "response_format", "json_mode": {"type": "json_object"}}`。`arg` 是请求体顶层键名(不得占用保留键);`json_mode` 是 `json_mode=True` 时写入该键的取值。**未声明 = 用内置 OpenAI 兼容默认**(`response_format: {"type":"json_object"}`,开箱即用);`"structured_output": false` 表示该档案不支持(`json_mode=True` 被忽略 + 告警,不发任何字段)。逐调用 `response_format` 原样透传优先于本声明。kit 只做翻译,不含任何厂商名 |
1651
+ | 档案级 | `thinking` | 思考开关的注入声明:`{"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 只做翻译,不含任何厂商名 |
1652
+ | 档案级 | `structured_output` | 结构化输出的注入声明:`{"arg": "response_format", "json_mode": {"type": "json_object"}}`。`arg` 是请求体顶层键名(不得占用保留键);`json_mode` 是 `json_mode=True` 时写入该键的取值。**未声明 = 用内置 OpenAI 兼容默认**(`response_format: {"type":"json_object"}`,开箱即用);`"structured_output": false` 表示该档案不支持(`json_mode=True` 被忽略 + 告警,不发任何字段)。逐调用 `response_format` 原样透传优先于本声明。kit 只做翻译,不含任何厂商名 |
1621
1653
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1622
1654
 
1623
1655
  `thinking` 声明两种实测形态(按平台各自写入档案):
@@ -1639,11 +1671,11 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1639
1671
  ```
1640
1672
 
1641
1673
  > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1642
- > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1643
- > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1674
+ > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`;
1675
+ > 仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1644
1676
  > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1645
1677
  > 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
1646
- > 逐调用 `response_format`(0.6.8 起)。
1678
+ > 逐调用 `response_format`。
1647
1679
 
1648
1680
  ### 候选链切换语义(两类,勿混淆)
1649
1681
 
@@ -4,7 +4,7 @@ SDPY 统一工具集(Kit 体系),供 Python 项目开箱即用:
4
4
 
5
5
  - **统一数据库访问层**:PostgreSQL / MySQL / SQLite,三个 Kit API 完全一致,切换数据库只改一行 import;内置审计字段自动填充(`created_*` / `updated_*` / `deleted`)与软删除查询过滤;
6
6
  - **AI 多模型服务池**:chat / embedding / rerank / image 四类能力,多厂商候选链 + 故障自动转移;
7
- - **工具集**:Redis / 文件 / 雪花 ID / 离线授权签发与校验 / 文档互转 / Word 模板填充 / MinerU 解析 / Marker 本地解析(PDF + 图片 → md + images/) / 离线 docx 解析(md + images/) / Laya 类型化决策 / Decima 决策模型(选项乱序不变、置信度可门控) / 微信公众号文章抓取存储 / 通用网页文章抓取存储 / DeepSeek 搜索与抓取。
7
+ - **工具集**:Redis / 文件 / 雪花 ID / 离线授权签发与校验 / 文档互转 / Word 模板填充 / MinerU 解析 / Marker 本地解析(0.7.0 起暂停发布,需要请用 `sdpy-kit==0.6.10`) / 离线 docx 解析(md + images/) / Laya 类型化决策 / Decima 决策模型(选项乱序不变、置信度可门控) / 微信公众号文章抓取存储 / 通用网页文章抓取存储 / DeepSeek 搜索与抓取。
8
8
 
9
9
  > 要求 Python 3.13+
10
10
 
@@ -727,7 +727,7 @@ page = qb.paginate(page=1, per_page=20) # {"items", "total", ...}
727
727
 
728
728
  安装:`pip install "sdpy-kit[ai]"`
729
729
 
730
- 传输(0.6.11):chat / embedding / image 走 `openai` SDK(`>=3.44,<4`,
730
+ 传输:chat / embedding / image 走 `openai` SDK(`>=3.44,<4`,
731
731
  自带 `httpx2`),rerank 走 httpx 自研传输(SDK 无 `/rerank` 资源)。
732
732
  档案里的 `endpoint` **对前三类不再生效**——SDK 固定拼 `/chat/completions`、
733
733
  `/embeddings`、`/images/generations`,仅 rerank 与探活仍使用该字段。
@@ -753,14 +753,29 @@ 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` / `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
- | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表 |
759
+ | rerank | `rerank(query, documents, top_n=None) -> list[RerankHit]` | 返回 `RerankHit`(`index` / `score` / `document`)降序列表;**`top_n` 是返回条数上限,由 kit 本地裁剪**(服务端返回超量也按分数截断),`top_n=None` 返回全部命中 |
760
760
  | image | `generate_image(prompt, *, size=None, n=1, return_base64=False, ratio=None) -> ImageResult` | 文生图,返回 `ImageResult`(`url` / `b64` 至少一个非空) |
761
761
  | 管理 | `use(model_type, profile) -> None` | 切换该类型当前档案(`chat` / `embedding` / `rerank` / `image`) |
762
- | 管理 | `available(model_type) -> list[ProfileStatus]` | 各档案状态快照(含未配置与被剔除者) |
763
- | 管理 | `reload_config(*, model_type=None, reload_file=False, probe=True, immediate=False) -> ReloadResult` | 配置热重载,改密钥 / 档案后无需重启 |
762
+ | 管理 | `available(model_type) -> list[ProfileStatus]` | 各档案状态快照(含未配置密钥与被剔除者);**类型未在 `ai_models.json` 中配置时返回 `[]`**(不再抛异常),类型名非法仍抛 `AIConfigError` |
763
+ | 管理 | `reload_config(*, model_type=None, reload_file=False, probe=True, immediate=False) -> ReloadResult` | 配置热重载,改密钥 / 档案后无需重启;同时清空思考/结构化输出的告警去重表(修正档案后同一原因会重新提示) |
764
+
765
+ **可用性查询(`available`)**:`available` 返回的是**状态快照**,不是「可用档案列表」。
766
+ 判断某类型是否已配置用 `if not AI.available(t)`,不要靠捕获异常:
767
+
768
+ ```python
769
+ from kit.ai.runner import AI
770
+
771
+ profiles = AI.available("image") # ai_models.json 无 image 段 → []
772
+ if not profiles:
773
+ raise RuntimeError("尚未配置图像生成模型")
774
+ current = next((p for p in profiles if p.current), None)
775
+ ```
776
+
777
+ > 旧写法 `try: AI.available(t) except AIConfigError: ...` 仍可用(异常分支不再触发);
778
+ > `AI.available(t)[0]` 在类型未配置时会抛 `IndexError`,请改用上面的判空写法。
764
779
 
765
780
  ```python
766
781
  from kit.ai.runner import AI
@@ -772,30 +787,47 @@ with AI.chat_stream([{"role": "user", "content": "写一首短诗"}]) as stream:
772
787
 
773
788
  #### 流式收尾统计(思考型模型必备)
774
789
 
775
- 迭代器在流结束后可读六个只读属性(`reasoning_chars` / `content_chars` / `finish_reason` /
776
- `truncated` / `thinking_applied` / `json_mode_applied`),用于区分「模型真的没返回」与「输出预算被思考吃光」——思考型模型的推理
777
- token 计入 `max_tokens`,长上下文 + 小预算时常出现「返回 200 但正文为空」(同一套字段也可用于非流式 `ask` / `complete`,
778
- 见下文「结构化输出」):
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`,见下文「结构化输出」)。
795
+
796
+ `empty_kind` 把「正文为空」分成五类,不再一律归因为预算耗尽:
797
+
798
+ | 取值 | 判定 | 建议处置 |
799
+ |---|---|---|
800
+ | `budget_exhausted` | 被长度截断且有推理 | 调大 `max_tokens`,或对该调用传 `thinking=False` |
801
+ | `truncated_empty` | 被长度截断且无推理 | 调大 `max_tokens`;若模型支持独立推理预算,确认推理是否计入输出配额 |
802
+ | `reasoning_only` | 有推理、`finish_reason=stop`、正文 0 | 提高预算或换模型 |
803
+ | `no_finish` | 结束原因缺失或非 `stop`(端点未发送 `finish_reason`) | 可原样重试;连续出现检查端点 |
804
+ | `stopped_empty` | 无推理、`finish_reason=stop`、正文 0 | 检查提示词与模型输出 |
805
+
806
+ 正文非空时 `empty_kind is None`。判定只用「是否截断 + 是否有推理 + 结束原因」,不依赖 token 数(kit 目前不采集 `usage`):
779
807
 
780
808
  ```python
781
809
  with AI.chat_stream(messages, max_tokens=450) as stream:
782
810
  body = "".join(stream)
783
- if not body and stream.truncated and stream.reasoning_chars:
784
- raise ValueError(
785
- f"输出预算被思考消耗(推理 {stream.reasoning_chars} 字符),"
786
- "请调大 max_tokens 或对该调用传 thinking=False"
787
- )
811
+ if not body:
812
+ match stream.empty_kind:
813
+ case "budget_exhausted" | "truncated_empty":
814
+ ... # 调大 max_tokens(或配独立推理预算)
815
+ case "no_finish":
816
+ ... # 原样重试
788
817
  ```
789
818
 
819
+ `requested_max_tokens` 是本次**实际下发**的输出上限(调用方传值优先,否则取档案 `max_tokens`;都没有为 `None`),
820
+ 在发请求前写入,失败/中断时也可读——业务可据此判断「预算是否真的设够了」。
821
+
790
822
  - 推理字段兼容 `delta.reasoning_content`(GLM / DeepSeek / Qwen)与 `delta.reasoning`(sensenova):只计数不产出;
791
- - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` 判定是否被截断;
792
- - 正文为空且(被截断或发生过思考)时 kit 自行打 `WARNING`,含模型与 `max_tokens`;
823
+ - 推理字段名是 kit 内部白名单(当前就是上面两种):新 provider 若用别的字段名,`reasoning_chars` 会恒为 0——此时仍可用 `truncated` / `empty_kind` 判定;
824
+ - 正文为空时 kit 按 `empty_kind` 分级打 `WARNING`(含模型、`max_tokens`、`finish_reason` 与推荐处置),不再统一归因「预算被思考耗尽」;
793
825
  - `truncated` 覆盖 `finish_reason` 为 `length` 或 `max_tokens` 两种口径;
794
826
  - **字符数 ≠ token 数**(实测中文约 2~2.5 字符/token,各家口径不一),不要用 `reasoning_chars` 反推还差多少预算;
795
- - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;
827
+ - provider 不返回 `finish_reason` 时 `finish_reason is None`、`truncated is False`;正文为空则归为 `no_finish`(可原样重试);
796
828
  - 流式过程中读到的是尽力而为的中间值,**结束后才是最终值**;提前 `break` 时为截至中断的累计值。
797
829
 
798
- #### 逐调用思考开关(0.6.7)
830
+ #### 逐调用思考开关
799
831
 
800
832
  同一档案下按调用点切换思考(入库抽取关、章节生成开),不必复制档案:
801
833
 
@@ -814,14 +846,14 @@ kit 不认识任何厂商字段名,请求体字段由档案 `thinking` 声明
814
846
 
815
847
  `thinking_applied`(流式迭代器属性 / `StreamStats` 字段):`None` 未要求干预 / `True` 已按声明注入 / `False` 要求了但被忽略。
816
848
 
817
- > 这是对 0.6.5「不新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」结论的**有意修订**:
818
- > 当时的否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
849
+ > 设计说明:曾否决「新增 thinking / reasoning_effort 具名字段、只用 `extra_body`」,
850
+ > 该否决理由(候选链跨厂商,备胎收到未知字段会 400 → 冷却)已由「档案能力声明」机制解除——
819
851
  > 字段只发给声明了它的候选,未声明者不注入。思考**档位**(`low/medium/high`)仍然不做。
820
852
 
821
853
  > 首帧等待语义:每候选 60 秒内**一帧都没有**(含推理帧)才切下一候选;思考阶段的
822
854
  > 推理帧算活动并重置窗口,不会误判健康模型失败、也不会重放整段 prompt。
823
855
 
824
- #### 结构化输出(JSON 模式,0.6.8)
856
+ #### 结构化输出(JSON 模式)
825
857
 
826
858
  只要「合法 JSON 对象」用 `json_mode=True`;要约束字段用协议的 `response_format` 原样透传:
827
859
 
@@ -1146,7 +1178,7 @@ Md2DocKit.convert_md_to_pdf("report.md", mainfont="KaiTi")
1146
1178
 
1147
1179
  | 方法 | 说明 |
1148
1180
  |------|------|
1149
- | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体", reference_doc=None) -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1181
+ | `convert_md_to_docx(src_path, dest_path=None, *, generate_toc=False, mainfont="宋体") -> str` | Markdown → Word(可插目录、统一字体、图片内嵌) |
1150
1182
  | `convert_md_to_html(src_path, dest_path=None, *, title=None) -> str` | Markdown → 独立 HTML5 文件(图片输出 `<figure><img>`) |
1151
1183
  | `convert_md_to_pdf(src_path, dest_path=None, *, mainfont="SimSun") -> str` | Markdown → PDF(实验性,内部使用;图片等比限宽居中) |
1152
1184
 
@@ -1529,13 +1561,13 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1529
1561
 
1530
1562
  | 层级 | 字段 | 说明 |
1531
1563
  |------|------|------|
1532
- | 档案级 | `endpoint` | 请求路径。**0.6.10 起仅 rerank 生效**:chat / embedding / image 由 openai SDK 固定拼路径,本字段被忽略(保留仅为兼容旧配置,不报错) |
1564
+ | 档案级 | `endpoint` | 请求路径。**仅 rerank 生效**:chat / embedding / image 由 openai SDK 固定拼路径,本字段被忽略(保留仅为兼容旧配置,不报错) |
1533
1565
  | 档案级 | `timeout` | 请求超时秒数(默认 60);SDK 侧为单次调用总超时 |
1534
1566
  | 档案级 | `max_tokens` | 输出上限(chat 用);`AI.chat_ask/chat_complete/chat_stream` 也可按调用点传参,逐级回落:调用点 → 档案 → provider 默认 |
1535
1567
  | 档案级 | `retries` | 5xx 重试次数(默认 2;超时不重试,候选链切换才是有效重试) |
1536
1568
  | 档案级 | `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)` 生效 |
1537
- | 档案级 | `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 只做翻译,不含任何厂商名 |
1538
- | 档案级 | `structured_output` | 结构化输出的注入声明(0.6.8):`{"arg": "response_format", "json_mode": {"type": "json_object"}}`。`arg` 是请求体顶层键名(不得占用保留键);`json_mode` 是 `json_mode=True` 时写入该键的取值。**未声明 = 用内置 OpenAI 兼容默认**(`response_format: {"type":"json_object"}`,开箱即用);`"structured_output": false` 表示该档案不支持(`json_mode=True` 被忽略 + 告警,不发任何字段)。逐调用 `response_format` 原样透传优先于本声明。kit 只做翻译,不含任何厂商名 |
1569
+ | 档案级 | `thinking` | 思考开关的注入声明:`{"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 只做翻译,不含任何厂商名 |
1570
+ | 档案级 | `structured_output` | 结构化输出的注入声明:`{"arg": "response_format", "json_mode": {"type": "json_object"}}`。`arg` 是请求体顶层键名(不得占用保留键);`json_mode` 是 `json_mode=True` 时写入该键的取值。**未声明 = 用内置 OpenAI 兼容默认**(`response_format: {"type":"json_object"}`,开箱即用);`"structured_output": false` 表示该档案不支持(`json_mode=True` 被忽略 + 告警,不发任何字段)。逐调用 `response_format` 原样透传优先于本声明。kit 只做翻译,不含任何厂商名 |
1539
1571
  | 类型级 | `chain_timeout` | 整链预算秒数(与 `default_profile` 同级);门面同步等待上限。未配置时按 `sum(候选 timeout) + 10s` 自适应,也可用环境变量 `KIT_AI_CHAIN_TIMEOUT` 全局覆盖 |
1540
1572
 
1541
1573
  `thinking` 声明两种实测形态(按平台各自写入档案):
@@ -1557,11 +1589,11 @@ AI 功能需要在项目根目录提供 `config/ai_models.json`(chat / embeddi
1557
1589
  ```
1558
1590
 
1559
1591
  > **`extra_body` 的用法边界**:`extra_body` 是**档案级**的固有配置,无法按调用点切换。需要
1560
- > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`
1561
- > (0.6.7 起);仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1592
+ > 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`;
1593
+ > 仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
1562
1594
  > (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
1563
1595
  > 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
1564
- > 逐调用 `response_format`(0.6.8 起)。
1596
+ > 逐调用 `response_format`。
1565
1597
 
1566
1598
  ### 候选链切换语义(两类,勿混淆)
1567
1599
 
@@ -23,7 +23,7 @@ Author: Clark Chang
23
23
  数据库访问通过 kit.db 子包提供(基于 SQLModel)。
24
24
  """
25
25
 
26
- __version__ = "0.7.0"
26
+ __version__ = "0.7.2"
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
@@ -47,6 +51,50 @@ _REASONING_KEYS: Final[tuple[str, ...]] = ("reasoning_content", "reasoning")
47
51
  # 截断判定:OpenAI 系给 length,部分兼容端点给 max_tokens
48
52
  _TRUNCATED_REASONS: Final[frozenset[str]] = frozenset({"length", "max_tokens"})
49
53
 
54
+ # 空正文诊断分级 → 告警文案(正文为 0 时才有分级,见 _classify_empty)
55
+ _EMPTY_HINTS: Final[dict[str, str]] = {
56
+ "budget_exhausted": "输出预算疑被思考耗尽(长度截断)",
57
+ "truncated_empty": "输出被长度截断、正文为空(无推理)",
58
+ "reasoning_only": "只产出推理、正文为空(finish=stop)",
59
+ "no_finish": "正文为空且未收到正常结束原因",
60
+ "stopped_empty": "无正文输出(finish=stop)",
61
+ }
62
+
63
+ _EMPTY_ACTIONS: Final[dict[str, str]] = {
64
+ "budget_exhausted": "(可调大 max_tokens,或对该调用传 thinking=False 关闭思考)",
65
+ "truncated_empty": "(调大 max_tokens;若模型支持独立推理预算,确认推理是否计入输出配额)",
66
+ "reasoning_only": "(推理未触顶但正文为空,可提高预算或换模型)",
67
+ "no_finish": "(端点未发送 finish_reason 或异常结束,可原样重试)",
68
+ "stopped_empty": "(检查提示词与模型输出)",
69
+ }
70
+
71
+
72
+ def _classify_empty(
73
+ *,
74
+ content_chars: int,
75
+ reasoning_chars: int,
76
+ finish_reason: str | None,
77
+ truncated: bool,
78
+ ) -> str | None:
79
+ """空正文诊断分级;正文非空返回 None。
80
+
81
+ - ``budget_exhausted``:被截断且有推理(预算被思考吃满)
82
+ - ``truncated_empty``:被截断且无推理(一个正文字符都没出)
83
+ - ``reasoning_only``:有推理、``finish_reason=stop``
84
+ - ``no_finish``:结束原因缺失或非 stop(无论有无推理)
85
+ - ``stopped_empty``:无推理、``finish_reason=stop``
86
+ """
87
+ if content_chars:
88
+ return None
89
+ if truncated:
90
+ return "budget_exhausted" if reasoning_chars else "truncated_empty"
91
+ if reasoning_chars:
92
+ return "reasoning_only" if finish_reason == "stop" else "no_finish"
93
+ if finish_reason == "stop":
94
+ return "stopped_empty"
95
+ return "no_finish"
96
+
97
+
50
98
  # 「无注入值」哨兵:区分「不注入」与「注入值为 None」
51
99
  _UNSET: Final[object] = object()
52
100
 
@@ -71,6 +119,15 @@ def _warn_once(
71
119
  logger.log(level, f"{kind}:{reason}(档案 {profile_name})")
72
120
 
73
121
 
122
+ def reset_warned() -> None:
123
+ """清空告警去重表(配置热重载后重新提示,避免旧原因被永久压制)。
124
+
125
+ 供 :func:`kit.ai.runner.reload_config` 调用;线程安全。
126
+ """
127
+ with _WARNED_LOCK:
128
+ _WARNED.clear()
129
+
130
+
74
131
  def _resolve_thinking(
75
132
  profile: ModelProfile, thinking: bool | None
76
133
  ) -> tuple[object, bool | None, str | None, int]:
@@ -125,6 +182,13 @@ class StreamStats:
125
182
  True 已按档案声明注入 / False 要求了但档案完全没有 thinking 声明
126
183
  json_mode_applied: 结构化输出是否生效——None 本次未要求注入 /
127
184
  True 已注入 / False 要求了但档案声明不支持(已忽略)
185
+ requested_max_tokens: 本次实际下发的输出上限(调用方 → 档案回落);
186
+ None 表示未下发、由 provider 默认值决定。在发出请求前写入,
187
+ 失败/中断时也可读
188
+
189
+ 只读派生属性:``truncated``(是否截断)、``empty_kind``(正文为空时的
190
+ 诊断分级:``budget_exhausted`` / ``truncated_empty`` / ``reasoning_only`` /
191
+ ``no_finish`` / ``stopped_empty``;正文非空为 None)。
128
192
  """
129
193
 
130
194
  reasoning_chars: int = 0
@@ -132,12 +196,29 @@ class StreamStats:
132
196
  finish_reason: str | None = None
133
197
  thinking_applied: bool | None = None
134
198
  json_mode_applied: bool | None = None
199
+ requested_max_tokens: int | None = None
135
200
 
136
201
  @property
137
202
  def truncated(self) -> bool:
138
203
  """是否因输出长度上限被截断。"""
139
204
  return self.finish_reason in _TRUNCATED_REASONS
140
205
 
206
+ @property
207
+ def empty_kind(self) -> str | None:
208
+ """正文为空时的诊断分级;正文非空返回 None。
209
+
210
+ 取值:``budget_exhausted`` / ``truncated_empty`` / ``reasoning_only`` /
211
+ ``no_finish`` / ``stopped_empty``。不依赖 token 数量,用「是否截断 +
212
+ 是否有推理 + 结束原因」区分「预算吃满」「截断且无正文」「只推理」
213
+ 「未正常收尾」「正常停住」。
214
+ """
215
+ return _classify_empty(
216
+ content_chars=self.content_chars,
217
+ reasoning_chars=self.reasoning_chars,
218
+ finish_reason=self.finish_reason,
219
+ truncated=self.truncated,
220
+ )
221
+
141
222
  def reset(self) -> None:
142
223
  """清零(候选链切换到下一个档案前调用)。"""
143
224
  self.reasoning_chars = 0
@@ -145,6 +226,7 @@ class StreamStats:
145
226
  self.finish_reason = None
146
227
  self.thinking_applied = None
147
228
  self.json_mode_applied = None
229
+ self.requested_max_tokens = None
148
230
 
149
231
 
150
232
  @dataclass(frozen=True)
@@ -317,7 +399,8 @@ class ChatService(SdkServiceBase):
317
399
  response_format: 协议级原样透传(含 ``json_schema`` 严格模式);
318
400
  与 ``json_mode`` 互斥,schema 由调用方负责
319
401
  stats: 收尾统计累加器(可选);传入后可读 ``finish_reason`` /
320
- ``truncated`` / ``reasoning_chars`` / ``json_mode_applied``
402
+ ``truncated`` / ``empty_kind`` / ``requested_max_tokens`` /
403
+ ``reasoning_chars`` / ``json_mode_applied``
321
404
  deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
322
405
  5xx 重试预算;None 表示不限制
323
406
  """
@@ -331,6 +414,9 @@ class ChatService(SdkServiceBase):
331
414
  stats=stats,
332
415
  )
333
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
334
420
  self._debug_payload(
335
421
  {
336
422
  "model": self._profile.model,
@@ -361,7 +447,21 @@ class ChatService(SdkServiceBase):
361
447
  "(请调大 max_tokens,或对该调用传 thinking=False 关闭思考)"
362
448
  )
363
449
  raise AIResponseError(f"content 类型异常: {type(content).__name__}")
364
- if finish in _TRUNCATED_REASONS and (reasoning or not content):
450
+ truncated = finish in _TRUNCATED_REASONS
451
+ kind = _classify_empty(
452
+ content_chars=len(content),
453
+ reasoning_chars=len(reasoning),
454
+ finish_reason=finish,
455
+ truncated=truncated,
456
+ )
457
+ if kind is not None:
458
+ self._log_empty(
459
+ kind=kind,
460
+ max_tokens=effective_max_tokens,
461
+ finish=finish,
462
+ reasoning_chars=len(reasoning),
463
+ )
464
+ elif truncated:
365
465
  logger.warning(
366
466
  f"{self._log_desc()} 输出被长度截断(finish_reason={finish}):"
367
467
  f"推理 {len(reasoning)} 字符 / 正文 {len(content)} 字符"
@@ -420,11 +520,12 @@ class ChatService(SdkServiceBase):
420
520
  kit 不做增量解析
421
521
  response_format: 协议级原样透传(与 ``json_mode`` 互斥)
422
522
  stats: 收尾统计累加器(可选);None 时内部创建后丢弃——需要
423
- ``reasoning_chars`` / ``finish_reason`` / ``truncated`` 时
424
- 由调用方传入一个实例
523
+ ``reasoning_chars`` / ``finish_reason`` / ``truncated`` /
524
+ ``empty_kind`` / ``requested_max_tokens`` 时由调用方传入
425
525
 
426
526
  推理内容(``delta.reasoning_content`` / ``delta.reasoning``)只计数
427
- 不产出;正文为空且被截断(或发生过思考)时输出 warning。
527
+ 不产出;正文为空时按 ``empty_kind`` 分级输出 warning(不依赖是否有
528
+ 推理或是否截断)。
428
529
  """
429
530
  stats = stats if stats is not None else StreamStats()
430
531
  request = ChatRequest(
@@ -438,6 +539,8 @@ class ChatService(SdkServiceBase):
438
539
  stats=stats,
439
540
  )
440
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
441
544
  desc = self._log_desc()
442
545
  # 惰性 Start:异步生成器体在首次 __anext__ 前不执行,不消费不打日志
443
546
  logger.info(f"********** {desc} ********** Start")
@@ -476,12 +579,7 @@ class ChatService(SdkServiceBase):
476
579
  note = "(中断)"
477
580
  raise
478
581
  else:
479
- effective_max_tokens = (
480
- request.max_tokens
481
- if request.max_tokens is not None
482
- else self._profile.max_tokens
483
- )
484
- self._warn_if_budget_eaten(stats, effective_max_tokens)
582
+ self._warn_if_empty(stats, effective_max_tokens)
485
583
  finally:
486
584
  log = logger.error if failed else logger.info
487
585
  log(
@@ -489,15 +587,29 @@ class ChatService(SdkServiceBase):
489
587
  f"耗时 {time.perf_counter() - start:.1f}秒{note}"
490
588
  )
491
589
 
492
- def _warn_if_budget_eaten(
493
- self, stats: StreamStats, max_tokens: object
494
- ) -> None:
495
- """正文为空且(被截断或有推理)时告警:讲清「预算被思考吃掉」。"""
496
- if stats.content_chars or not (stats.truncated or stats.reasoning_chars):
590
+ def _warn_if_empty(self, stats: StreamStats, max_tokens: int | None) -> None:
591
+ """正文为空时按诊断分级告警(不再一律归因为「预算被思考耗尽」)。"""
592
+ kind = stats.empty_kind
593
+ if kind is None:
497
594
  return
595
+ self._log_empty(
596
+ kind=kind,
597
+ max_tokens=max_tokens,
598
+ finish=stats.finish_reason,
599
+ reasoning_chars=stats.reasoning_chars,
600
+ )
601
+
602
+ def _log_empty(
603
+ self,
604
+ *,
605
+ kind: str,
606
+ max_tokens: int | None,
607
+ finish: str | None,
608
+ reasoning_chars: int,
609
+ ) -> None:
610
+ """输出空正文诊断日志(``ask`` / ``stream`` 共用同一套文案)。"""
498
611
  logger.warning(
499
- f"{self._log_desc()} 输出预算疑被思考耗尽:max_tokens={max_tokens},"
500
- f"finish_reason={stats.finish_reason},"
501
- f"推理 {stats.reasoning_chars} 字符 / 正文 0 字符"
502
- "(可调大 max_tokens,或对该调用传 thinking=False 关闭思考)"
612
+ f"{self._log_desc()} {_EMPTY_HINTS[kind]}:max_tokens={max_tokens},"
613
+ f"finish_reason={finish},推理 {reasoning_chars} 字符 / 正文 0 字符"
614
+ f"{_EMPTY_ACTIONS[kind]}"
503
615
  )
@@ -23,6 +23,7 @@ Author: Clark Chang
23
23
  继承链:
24
24
  AIError 基类
25
25
  ├── AIConfigError 档案文件缺失/非法、档案不存在、密钥未配置、必填字段缺失
26
+ │ └── AIModelTypeNotConfiguredError 类型名合法但档案未配置该类型段
26
27
  ├── AIRequestError 网络异常、HTTP >= 400
27
28
  │ ├── AITimeoutError 请求超时(可用于重试策略)
28
29
  │ └── AIAggregateError 候选链聚合异常(全部候选失败或整链预算耗尽)
@@ -38,6 +39,15 @@ class AIConfigError(AIError):
38
39
  """模型档案配置异常(文件缺失/非法、档案不存在、密钥未配置、必填字段缺失)"""
39
40
 
40
41
 
42
+ class AIModelTypeNotConfiguredError(AIConfigError):
43
+ """类型名合法但 ``ai_models.json`` 未配置该类型段(可预期的空态)。
44
+
45
+ ``AI.available(type)`` 对这种情况返回空列表;其余 ``AIConfigError``
46
+ (类型名非法、文件缺失、注册表未加载)仍照常抛出。继承 ``AIConfigError``
47
+ 使既有 ``except AIConfigError`` 捕获点零改动。
48
+ """
49
+
50
+
41
51
  class AIRequestError(AIError):
42
52
  """模型服务请求异常(网络异常、HTTP >= 400)。
43
53
 
@@ -20,14 +20,16 @@ Author: Clark Chang
20
20
  from kit.ai.chat_service import ChatService
21
21
  from kit.ai.embedding_service import EmbeddingService
22
22
  from kit.ai.image_service import ImageService
23
- from kit.ai.profiles import ModelProfile, get_model_registry, with_api_key
23
+ from kit.ai.profiles import (
24
+ MODEL_TYPES,
25
+ ModelProfile,
26
+ get_model_registry,
27
+ with_api_key,
28
+ )
24
29
  from kit.ai.rerank_service import RerankService
25
30
 
26
31
  __all__ = ["ModelBuilder"]
27
32
 
28
- # 支持的模型类型
29
- _MODEL_TYPES: tuple[str, ...] = ("chat", "embedding", "rerank", "image")
30
-
31
33
  Service = ChatService | EmbeddingService | RerankService | ImageService
32
34
 
33
35
 
@@ -87,7 +89,7 @@ class ModelBuilder:
87
89
  return ImageService(profile)
88
90
  case _:
89
91
  raise ValueError(
90
- f"未知模型类型: {model_type},支持: {', '.join(_MODEL_TYPES)}"
92
+ f"未知模型类型: {model_type},支持: {', '.join(MODEL_TYPES)}"
91
93
  )
92
94
 
93
95
  @classmethod
@@ -18,18 +18,17 @@ Author: Clark Chang
18
18
  """
19
19
 
20
20
  # date: 2026-11-04
21
- """模型档案轻探测客户端(0.7.0 起更名 ``ProbeClient``)。
21
+ """模型档案轻探测客户端(``ProbeClient``)。
22
22
 
23
23
  破坏性变更:``post_json`` / ``stream_json`` 已删除——请求发送改由
24
24
  :class:`kit.ai.sdk_client.SdkClient` 承担,rerank 走
25
25
  :class:`kit.ai.rerank_transport.RerankTransport`。本类只保留
26
26
  ``list_models()`` / ``close()``,供业务侧做「密钥是否有效」的连通性测试。
27
27
 
28
- ``ModelClient`` 作为弃用别名保留一个版本(访问时触发 ``DeprecationWarning``)。
28
+ 0.7.0 由 ``ModelClient`` 改名;0.7.1 起**弃用别名已删除**,旧名不可导入
29
+ (``from kit.ai.model_client import ModelClient`` 抛 ``ImportError``)。
29
30
  """
30
31
 
31
- import warnings
32
-
33
32
  from kit.ai.profiles import ModelProfile
34
33
  from kit.ai.sdk_client import SdkClient
35
34
  from kit.ai.types import JSONObject
@@ -40,18 +39,14 @@ __all__ = ["ProbeClient"]
40
39
  class ProbeClient:
41
40
  """单档案连通性探测客户端(全异步)。
42
41
 
43
- 0.7.0 起由 ``ModelClient`` 改名:它只做 ``list_models`` 探活,
44
- 真正的传输在 :class:`kit.ai.sdk_client.SdkClient`。
42
+ 它只做 ``list_models`` 探活,真正的传输在
43
+ :class:`kit.ai.sdk_client.SdkClient`。
45
44
  """
46
45
 
47
46
  def __init__(self, profile: ModelProfile) -> None:
48
47
  self._profile: ModelProfile = profile
49
48
  self._client: SdkClient = SdkClient(profile)
50
49
 
51
- @property
52
- def model(self) -> str:
53
- return self._profile.model
54
-
55
50
  async def list_models(self) -> JSONObject:
56
51
  """``GET {base_url}/models`` 轻探测(独立 10 秒超时)。
57
52
 
@@ -65,15 +60,3 @@ class ProbeClient:
65
60
 
66
61
  async def close(self) -> None:
67
62
  await self._client.close()
68
-
69
-
70
- def __getattr__(name: str) -> object:
71
- """0.7.0 起 ``ModelClient`` 更名为 ``ProbeClient``:保留别名并告警。"""
72
- if name == "ModelClient":
73
- warnings.warn(
74
- "ModelClient 已更名为 ProbeClient(0.7.0),请改用 ProbeClient",
75
- DeprecationWarning,
76
- stacklevel=2,
77
- )
78
- return ProbeClient
79
- raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -42,7 +42,12 @@ from typing import Final
42
42
 
43
43
  from kit.ai.chat_service import ChatService
44
44
  from kit.ai.embedding_service import EmbeddingService
45
- from kit.ai.exceptions import AIConfigError, AIError, AIRequestError
45
+ from kit.ai.exceptions import (
46
+ AIConfigError,
47
+ AIError,
48
+ AIModelTypeNotConfiguredError,
49
+ AIRequestError,
50
+ )
46
51
  from kit.ai.image_service import ImageService
47
52
  from kit.ai.model_builder import Service
48
53
  from kit.ai.model_client import ProbeClient
@@ -369,8 +374,16 @@ class AIServicePool:
369
374
  logger.info(f"已切换 {model_type} 当前使用档案: {profile}")
370
375
 
371
376
  def available(self, model_type: str) -> list[ProfileStatus]:
372
- """该类型全部档案状态快照(含未配置与被剔除者),链头标记 current。"""
373
- chain = self._ensure_chain(model_type)
377
+ """该类型全部档案状态快照(含未配置密钥与被剔除者),链头标记 current。
378
+
379
+ 类型名合法但 ``ai_models.json`` 未配置该段时返回空列表(可预期空态);
380
+ 类型名非法、注册表未加载等真正的配置错误仍抛 ``AIConfigError``。
381
+ """
382
+ try:
383
+ chain = self._ensure_chain(model_type)
384
+ except AIModelTypeNotConfiguredError as exc:
385
+ logger.debug(f"{model_type} 状态快照为空: {exc}")
386
+ return []
374
387
  now = time.monotonic()
375
388
  with self._lock:
376
389
  preferred = self._preferred.get(model_type)
@@ -39,14 +39,14 @@ from typing import Any, Final, Literal, Self
39
39
  from dotenv import load_dotenv
40
40
  from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
41
41
 
42
- from kit.ai.exceptions import AIConfigError
42
+ from kit.ai.exceptions import AIConfigError, AIModelTypeNotConfiguredError
43
43
  from kit.config.kit_config import get_settings
44
44
  from kit.config.log_config import get_default_logger
45
45
 
46
46
  logger = get_default_logger(__name__)
47
47
 
48
48
  # 支持的模型类型
49
- _MODEL_TYPES: Final[tuple[str, ...]] = ("chat", "embedding", "rerank", "image")
49
+ MODEL_TYPES: Final[tuple[str, ...]] = ("chat", "embedding", "rerank", "image")
50
50
 
51
51
  # 文件缺失报错时附带的最小可用模板(api_key 为环境变量名,密钥配置在 .env)
52
52
  _MINIMAL_MODELS_JSON: Final[str] = (
@@ -152,11 +152,6 @@ class ModelProfile:
152
152
  thinking: ThinkingSwitchSpec | None = None
153
153
  structured_output: StructuredOutputSpec | None = None
154
154
 
155
- @property
156
- def url(self) -> str:
157
- """拼接完整请求 URL(base_url 去尾斜杠 + endpoint)"""
158
- return self.base_url.rstrip("/") + self.endpoint
159
-
160
155
 
161
156
  class ProfileState(enum.Enum):
162
157
  """档案运行时状态(候选链与状态快照使用)。"""
@@ -171,7 +166,7 @@ class ProfileState(enum.Enum):
171
166
 
172
167
  @dataclass(frozen=True)
173
168
  class ProfileStatus:
174
- """档案状态快照行(``ModelRegistry.available`` / ``AI.available`` 返回)。
169
+ """档案状态快照行(``AIServicePool.available`` / ``AI.available`` 返回)。
175
170
 
176
171
  Attributes:
177
172
  name: 档案名
@@ -319,6 +314,16 @@ class TypeConfig(BaseModel):
319
314
  chain_timeout: int | None = Field(default=None, gt=0)
320
315
  profiles: dict[str, ProfileConfig]
321
316
 
317
+ @model_validator(mode="after")
318
+ def _validate_default_profile(self) -> Self:
319
+ """默认档案必须在该类型的 profiles 中(加载期即报,避免运行期 KeyError)。"""
320
+ if self.default_profile not in self.profiles:
321
+ raise ValueError(
322
+ f"default_profile 未在 profiles 中定义: {self.default_profile}"
323
+ f"(已知档案: {', '.join(sorted(self.profiles)) or '无'})"
324
+ )
325
+ return self
326
+
322
327
 
323
328
  class RegistryConfig(BaseModel):
324
329
  """模型档案文件整体结构(顶层对象)"""
@@ -329,10 +334,22 @@ class RegistryConfig(BaseModel):
329
334
  image: TypeConfig | None = None
330
335
 
331
336
  def type_config(self, model_type: str) -> TypeConfig:
332
- config = getattr(self, model_type, None)
333
- if not isinstance(config, TypeConfig):
334
- available = ", ".join(_MODEL_TYPES)
337
+ """取某类型的档案集合,区分「类型名非法」与「类型未配置」两种情况。
338
+
339
+ Raises:
340
+ AIConfigError: 类型名不在四类之内(调用方写错)。
341
+ AIModelTypeNotConfiguredError: 类型名合法但 JSON 缺该段(可预期空态,
342
+ ``AI.available`` 据此返回空列表)。
343
+ """
344
+ if model_type not in MODEL_TYPES:
345
+ available = ", ".join(MODEL_TYPES)
335
346
  raise AIConfigError(f"模型类型不存在: {model_type},可用类型: {available}")
347
+ config = getattr(self, model_type)
348
+ if config is None:
349
+ raise AIModelTypeNotConfiguredError(
350
+ f"模型类型未配置: {model_type}"
351
+ f"(ai_models.json 缺少 {model_type} 段)"
352
+ )
336
353
  return config
337
354
 
338
355
 
@@ -384,7 +401,7 @@ class ModelRegistry:
384
401
  except (OSError, ValidationError) as exc:
385
402
  raise AIConfigError(f"模型档案文件解析失败: {config_path}: {exc}") from exc
386
403
 
387
- loaded_types = [t for t in _MODEL_TYPES if getattr(self._config, t) is not None]
404
+ loaded_types = [t for t in MODEL_TYPES if getattr(self._config, t) is not None]
388
405
  logger.info(
389
406
  f"AI 模型档案已加载: {config_path},类型: {', '.join(loaded_types) or '无'}"
390
407
  )
@@ -519,6 +536,15 @@ def with_api_key(profile: ModelProfile, api_key: str) -> ModelProfile:
519
536
  return replace(profile, api_key=api_key)
520
537
 
521
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
+
522
548
  @lru_cache(maxsize=1)
523
549
  def get_model_registry() -> ModelRegistry:
524
550
  """获取全局单例 ModelRegistry(首次调用自动 load 默认档案文件)。"""
@@ -113,15 +113,21 @@ class RerankService(LoggingServiceBase):
113
113
  """对候选文档重排序,返回按分数降序的命中列表。
114
114
 
115
115
  Args:
116
+ top_n: 返回条数上限。kit 在本地保证(服务端返回超量也按分数
117
+ 截断到 ``top_n``);``None`` 返回服务端全部命中。``top_n <= 0``
118
+ 直接返回空列表,不发请求
116
119
  deadline: 整链预算截止时刻(``time.monotonic()`` 秒),约束
117
120
  5xx 重试预算;None 表示不限制
118
121
  """
119
122
  if not documents:
120
123
  return []
124
+ if top_n is not None and top_n <= 0:
125
+ return []
121
126
  payload = self._build_payload(RerankRequest(query, documents, top_n=top_n))
122
127
  with self._log_call():
123
128
  data = await self._client.post_json(payload, deadline=deadline)
124
- return self._parse_response(data, documents)
129
+ hits = self._parse_response(data, documents)
130
+ return hits[:top_n] if top_n is not None else hits
125
131
 
126
132
  async def close(self) -> None:
127
133
  await self._client.close()
@@ -22,7 +22,7 @@ Author: Clark Chang
22
22
 
23
23
  `openai` SDK 不提供 ``/rerank`` 资源,故 rerank 保留自研传输:单次 JSON
24
24
  POST、5xx 指数退避重试、429 ``Retry-After`` 解析。语义与 0.6.9 的
25
- ``ModelClient`` 完全一致。
25
+ 通用传输实现完全一致。
26
26
  """
27
27
 
28
28
  import asyncio
@@ -70,7 +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
+ from kit.ai.chat_service import StreamStats, reset_warned
74
74
  from kit.ai.exceptions import AIConfigError, AITimeoutError
75
75
  from kit.ai.failover import (
76
76
  FailoverChatService,
@@ -80,7 +80,7 @@ from kit.ai.failover import (
80
80
  )
81
81
  from kit.ai.model_builder import Service
82
82
  from kit.ai.pool import AIServicePool
83
- from kit.ai.profiles import ProfileStatus, get_model_registry
83
+ from kit.ai.profiles import MODEL_TYPES, ProfileStatus, get_model_registry
84
84
  from kit.ai.image_service import ImageResult
85
85
  from kit.ai.rerank_service import RerankHit
86
86
  from kit.config.kit_config import get_settings
@@ -96,8 +96,8 @@ _EMBEDDING = "embedding"
96
96
  _RERANK = "rerank"
97
97
  _IMAGE = "image"
98
98
 
99
- # 支持的模型类型集合(use/available 校验与错误提示用)
100
- _SERVICE_TYPES: Final[frozenset[str]] = frozenset({_CHAT, _EMBEDDING, _RERANK, _IMAGE})
99
+ # 支持的模型类型集合(use/available 校验与错误提示用,单一数据源见 profiles)
100
+ _SERVICE_TYPES: Final[frozenset[str]] = frozenset(MODEL_TYPES)
101
101
 
102
102
  # 常驻链式服务联合类型(按模型类型缓存于后台线程)
103
103
  FailoverService = (
@@ -279,7 +279,10 @@ class _BackgroundLoop:
279
279
  self._get_pool().prefer(model_type, profile)
280
280
 
281
281
  def available(self, model_type: str) -> list[ProfileStatus]:
282
- """返回该类型全部档案状态快照(含未配置者),链头标记 current。"""
282
+ """返回该类型全部档案状态快照(含未配置密钥者),链头标记 current。
283
+
284
+ 类型未在 ``ai_models.json`` 中配置时返回空列表。
285
+ """
283
286
  return self._get_pool().available(model_type)
284
287
 
285
288
  # ---------- 投递 ----------
@@ -681,7 +684,11 @@ class AI:
681
684
  def rerank(
682
685
  query: str, documents: list[str], top_n: int | None = None
683
686
  ) -> list[RerankHit]:
684
- """对候选文档重排序,返回 ``RerankHit`` 降序列表(原索引、分数、原文)。"""
687
+ """对候选文档重排序,返回 ``RerankHit`` 降序列表(原索引、分数、原文)。
688
+
689
+ ``top_n`` 是返回条数上限,由 kit 本地保证(服务端返回超量也按分数
690
+ 截断);``None`` 返回服务端全部命中。
691
+ """
685
692
  timeout = _facade_timeout(_RERANK)
686
693
  deadline = time.monotonic() + timeout
687
694
  return _background.run(
@@ -737,17 +744,18 @@ class AI:
737
744
 
738
745
  @staticmethod
739
746
  def available(model_type: str) -> list[ProfileStatus]:
740
- """返回某类型全部档案状态快照(含未配置与被剔除者),链头标 current。
747
+ """返回某类型全部档案状态快照(含未配置密钥与被剔除者),链头标 current。
741
748
 
742
749
  Args:
743
750
  model_type: 模型类型(chat / embedding / rerank / image)
744
751
 
745
752
  Returns:
746
753
  ``ProfileStatus`` 列表(name/model/state/current/last_error/
747
- last_ok_at),供业务展示与切换决策。
754
+ last_ok_at),供业务展示与切换决策。**类型在 ``ai_models.json``
755
+ 中未配置时返回空列表**(可预期空态,供界面显示「未配置」)。
748
756
 
749
757
  Raises:
750
- AIConfigError: 类型不存在或注册表未加载。
758
+ AIConfigError: 类型名非法(不在四类之内)或注册表未加载。
751
759
  """
752
760
  return _background.available(model_type)
753
761
 
@@ -1064,6 +1072,8 @@ def reload_config(
1064
1072
  # 4. 后台 loop 内摘链、清常驻服务并调度旧 service 关闭
1065
1073
  types = sorted(_SERVICE_TYPES) if model_type is None else [model_type]
1066
1074
  _background.invalidate(frozenset(types), probe=probe, immediate=immediate)
1075
+ # 4.1 重置告警去重:修正档案后同一原因应能再次提示(去重键不含配置指纹)
1076
+ reset_warned()
1067
1077
  # 5. 快照新 embedding,判定变更(model 或 dimension 任一变化即告警)
1068
1078
  current = _embedding_snapshot()
1069
1079
  changed = previous is not None and current is not None and previous != current
@@ -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:
@@ -1724,7 +1724,6 @@ class Md2DocKit:
1724
1724
  *,
1725
1725
  generate_toc: bool = False,
1726
1726
  mainfont: str = "宋体",
1727
- reference_doc: str | Path | None = None,
1728
1727
  ) -> str:
1729
1728
  """将 Markdown 文件转换为 Word(.docx)文件(基于 PiMD)
1730
1729
 
@@ -1732,9 +1731,7 @@ class Md2DocKit:
1732
1731
  生成 docx,支持标题、列表、表格(表头深蓝底加粗)、代码块、链接
1733
1732
  等语法;可通过 generate_toc 在文档开头插入目录。
1734
1733
  全文字体统一为 mainfont(默认宋体):转换后改写 docDefaults 与
1735
- 主题字体(正文/标题/表格全部生效),代码块保持等宽字体;
1736
- reference_doc 参数仅保留签名兼容(PiMD 的 md_to_docx 不接收参考
1737
- 模板,传入不生效)。
1734
+ 主题字体(正文/标题/表格全部生效),代码块保持等宽字体。
1738
1735
 
1739
1736
  图片(`![alt](相对路径)`)以内嵌图片段落回填:PiMD 会把行内图片
1740
1737
  降级为「alt 文本 + 超链接」,此处按占位标记替换为真实图片并居中,
@@ -1751,7 +1748,6 @@ class Md2DocKit:
1751
1748
  dest_path: 目标 Word 文件路径;None 时使用源文件名同目录加 .docx 后缀
1752
1749
  generate_toc: 是否在文档开头插入目录
1753
1750
  mainfont: 文档全局字体名(Word 字体名,如 "宋体"/"黑体"),默认宋体
1754
- reference_doc: 仅保留签名兼容;PiMD 不接收参考模板,传入不生效
1755
1751
 
1756
1752
  Returns:
1757
1753
  str: 实际写入的 Word 文件绝对路径
@@ -1769,11 +1765,6 @@ class Md2DocKit:
1769
1765
  """
1770
1766
  src, dest = _resolve_md_paths(src_path, dest_path, ".docx")
1771
1767
  logger.info(f"开始转换 Markdown 为 Word: {src.name}")
1772
- if reference_doc is not None:
1773
- # 保留签名兼容:PiMD 的 md_to_docx 不接收参考模板,传入不生效
1774
- logger.warning(
1775
- "reference_doc 参数已废弃(PiMD 不支持参考模板),传入不生效"
1776
- )
1777
1768
 
1778
1769
  # CJK 加粗预处理;临时 md 与源文件同目录,保证相对图片路径仍然可用
1779
1770
  processed = _normalize_cjk_bold(src.read_text(encoding="utf-8"))
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