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.
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/PKG-INFO +62 -30
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/README.md +61 -29
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/__init__.py +1 -1
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/chat_service.py +133 -21
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/exceptions.py +10 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/model_builder.py +7 -5
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/model_client.py +5 -22
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/pool.py +16 -3
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/profiles.py +38 -12
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/rerank_service.py +7 -1
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/rerank_transport.py +1 -1
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/runner.py +19 -9
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/sdk_client.py +2 -4
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/md2doc_kit.py +1 -10
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/.gitignore +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/__init__.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/_retry_after.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/_service_base.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/embedding_service.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/failover.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/image_service.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/ai/types.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/common_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/config/__init__.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/config/kit_config.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/config/log_config.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/__init__.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/article_io.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/article_models.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/deepseek_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/web_article_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/crawl/wx_article_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/__init__.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/audit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/base_db_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/crud.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/engine.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/exceptions.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/models/__init__.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/models/base.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/mysql_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/pg_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/query.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/session.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/sqlite_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/db/transaction.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/decima_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/__init__.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/anydoc_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/docx_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/marker_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/mineru_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/docs/rc_docparser_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/file_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/laya_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/license_kit.py +0 -0
- {sdpy_kit-0.7.0 → sdpy_kit-0.7.2}/kit/redis_kit.py +0 -0
- {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.
|
|
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 本地解析(
|
|
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
|
-
|
|
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
|
-
|
|
858
|
-
`truncated` / `thinking_applied` / `json_mode_applied
|
|
859
|
-
|
|
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
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
-
|
|
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
|
-
####
|
|
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
|
-
>
|
|
900
|
-
>
|
|
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
|
|
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="宋体"
|
|
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` |
|
|
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` |
|
|
1620
|
-
| 档案级 | `structured_output` |
|
|
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
|
-
>
|
|
1674
|
+
> 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`;
|
|
1675
|
+
> 仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
|
|
1644
1676
|
> (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
|
|
1645
1677
|
> 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
|
|
1646
|
-
> 逐调用 `response_format
|
|
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 本地解析(
|
|
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
|
-
|
|
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
|
-
|
|
776
|
-
`truncated` / `thinking_applied` / `json_mode_applied
|
|
777
|
-
|
|
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
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
-
-
|
|
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
|
-
####
|
|
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
|
-
>
|
|
818
|
-
>
|
|
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
|
|
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="宋体"
|
|
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` |
|
|
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` |
|
|
1538
|
-
| 档案级 | `structured_output` |
|
|
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
|
-
>
|
|
1592
|
+
> 「同一模型,长文开思考、短文关思考」并存时用档案 `thinking` 声明 + 逐调用 `thinking=False/True`;
|
|
1593
|
+
> 仅在无法用开关表达时才考虑复制档案 + `ModelBuilder.build_chat("<档案名>")`
|
|
1562
1594
|
> (门面侧 `AI.use("chat", "<档案名>")` 是进程级切换,非并发安全)。
|
|
1563
1595
|
> 结构化输出同理:档案声明只给 `json_mode=True` 提供取值,按调用点变化的 schema 一律走
|
|
1564
|
-
> 逐调用 `response_format
|
|
1596
|
+
> 逐调用 `response_format`。
|
|
1565
1597
|
|
|
1566
1598
|
### 候选链切换语义(两类,勿混淆)
|
|
1567
1599
|
|
|
@@ -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
|
|
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`` / ``
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
493
|
-
|
|
494
|
-
|
|
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()}
|
|
500
|
-
f"finish_reason={
|
|
501
|
-
f"
|
|
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
|
|
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(
|
|
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
|
-
"""
|
|
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``
|
|
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
|
-
|
|
44
|
-
|
|
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
|
|
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
|
-
"""
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
"""档案状态快照行(``
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
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
|
-
|
|
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()
|
|
@@ -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(
|
|
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
|
-
"""
|
|
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
|
-
"""
|
|
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
|
图片(``)以内嵌图片段落回填: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
|
|
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
|