qi-coding-agent 0.1.0__py3-none-any.whl
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.
- qi_agent/__init__.py +18 -0
- qi_agent/abort.py +40 -0
- qi_agent/auth.py +216 -0
- qi_agent/cli.py +2178 -0
- qi_agent/compaction.py +475 -0
- qi_agent/config.py +409 -0
- qi_agent/docs/cli.md +188 -0
- qi_agent/docs/compaction.md +148 -0
- qi_agent/docs/configuration.md +89 -0
- qi_agent/docs/containerization.md +80 -0
- qi_agent/docs/docs.json +1 -0
- qi_agent/docs/environment-variables.md +47 -0
- qi_agent/docs/extensions.md +382 -0
- qi_agent/docs/how-qi-works.md +93 -0
- qi_agent/docs/index.md +61 -0
- qi_agent/docs/json.md +112 -0
- qi_agent/docs/keybindings.md +83 -0
- qi_agent/docs/models.md +138 -0
- qi_agent/docs/packages.md +155 -0
- qi_agent/docs/providers.md +288 -0
- qi_agent/docs/quickstart.md +109 -0
- qi_agent/docs/security.md +69 -0
- qi_agent/docs/session-format.md +299 -0
- qi_agent/docs/sessions.md +127 -0
- qi_agent/docs/settings.md +138 -0
- qi_agent/docs/skills.md +124 -0
- qi_agent/docs/slash-commands.md +55 -0
- qi_agent/docs/themes.md +78 -0
- qi_agent/docs/tui.md +109 -0
- qi_agent/docs/usage.md +112 -0
- qi_agent/export_html.py +95 -0
- qi_agent/extensions.py +2267 -0
- qi_agent/llm.py +609 -0
- qi_agent/loader.py +299 -0
- qi_agent/model_catalog.py +108 -0
- qi_agent/models.py +103 -0
- qi_agent/packages.py +430 -0
- qi_agent/paths.py +242 -0
- qi_agent/pep723.py +74 -0
- qi_agent/presets.py +293 -0
- qi_agent/prompt.py +150 -0
- qi_agent/registry.py +485 -0
- qi_agent/runner.py +584 -0
- qi_agent/runtime.py +1940 -0
- qi_agent/session.py +739 -0
- qi_agent/settings.py +501 -0
- qi_agent/share.py +111 -0
- qi_agent/system_prompt.py +278 -0
- qi_agent/theme.py +389 -0
- qi_agent/themes/dark.json +90 -0
- qi_agent/themes/light.json +89 -0
- qi_agent/titling.py +90 -0
- qi_agent/tools/__init__.py +349 -0
- qi_agent/tools/shell.py +276 -0
- qi_agent/trust.py +106 -0
- qi_agent/tui.py +6224 -0
- qi_agent/workspaces.py +116 -0
- qi_coding_agent-0.1.0.dist-info/METADATA +151 -0
- qi_coding_agent-0.1.0.dist-info/RECORD +62 -0
- qi_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- qi_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- qi_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
qi_agent/pep723.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""PEP 723 的内联脚本元数据(`# /// script` 块)。
|
|
2
|
+
|
|
3
|
+
目录通道的扩展**没有 dist 元数据** —— 它不是装上去的,是仓库/用户目录里的一个脚本。所以它的
|
|
4
|
+
依赖声明走 PEP 723:在 `extension.py` 里写一个 `# /// script` 块。解析结果用于两件事:
|
|
5
|
+
|
|
6
|
+
- **宿主依赖检查**(E11:把宿主写进依赖要报出来 —— 发行名 `qi-coding-agent`;旧名 `qi-agent` 另算一条)—— 与 pip 通道**对称**,不再只查装上的;
|
|
7
|
+
- `qi doctor` 展示声明(§5.5:声明仍然要做)。
|
|
8
|
+
|
|
9
|
+
**只解析,不安装**(安装是 `qi install` 的事,目标仍是**同一个解释器环境**,见 E13)。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import tomllib
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
#: 块头/块尾的形状(`# /// <type>` 与 `# ///`)。
|
|
19
|
+
_OPEN = re.compile(r"^# /// ([a-zA-Z0-9-]+)\s*$")
|
|
20
|
+
_CLOSE = "# ///"
|
|
21
|
+
|
|
22
|
+
#: 目录通道的固定入口名(§E9:一个目录一个扩展)。
|
|
23
|
+
ENTRY_FILE = "extension.py"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_inline_metadata(text: str) -> dict[str, dict]:
|
|
27
|
+
r"""解析所有 `# /// <type>` 块 → `{type: TOML}`。
|
|
28
|
+
|
|
29
|
+
**逐行扫描,而不是用规范里给的那个正则**:那个正则是"一个块"的形状,而它的内容组
|
|
30
|
+
(`(^#(| .*)$\s)+`)在**相邻两个块**之间是贪婪的 —— 第二块的行也都以 `#` 开头,于是
|
|
31
|
+
它会把两块合成一段去解析 TOML,失败后**整块被跳过**(现象是"文档里明明写了两块,
|
|
32
|
+
解析结果却是空的")。逐行扫描没有这个问题,也更好读。
|
|
33
|
+
|
|
34
|
+
**一块写坏只跳过那一块**:这层是辅助信息(依赖声明),不该因为一个 TOML 语法错误让
|
|
35
|
+
整个扩展装载不了 —— 装载本身有它自己的报错路径。
|
|
36
|
+
"""
|
|
37
|
+
out: dict[str, dict] = {}
|
|
38
|
+
lines = text.splitlines()
|
|
39
|
+
index = 0
|
|
40
|
+
while index < len(lines):
|
|
41
|
+
opened = _OPEN.match(lines[index])
|
|
42
|
+
if opened is None:
|
|
43
|
+
index += 1
|
|
44
|
+
continue
|
|
45
|
+
kind = str(opened.group(1))
|
|
46
|
+
body: list[str] = []
|
|
47
|
+
index += 1
|
|
48
|
+
while index < len(lines) and lines[index].strip() != _CLOSE:
|
|
49
|
+
if lines[index].startswith("#"):
|
|
50
|
+
body.append(lines[index][1:].lstrip(" ")) # 去掉一个 `#` 与紧随的空格
|
|
51
|
+
index += 1
|
|
52
|
+
index += 1 # 跳过结束标记
|
|
53
|
+
try:
|
|
54
|
+
out[kind] = tomllib.loads("\n".join(body))
|
|
55
|
+
except tomllib.TOMLDecodeError:
|
|
56
|
+
continue
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def dependencies_of(script: Path | str) -> list[str]:
|
|
61
|
+
"""一个脚本(或扩展目录)声明的依赖 —— `script` 块里的 `dependencies`。
|
|
62
|
+
|
|
63
|
+
收目录时自动找入口文件(`extension.py`)。没有块、文件读不到、字段不是列表 → `[]`。
|
|
64
|
+
"""
|
|
65
|
+
path = Path(script)
|
|
66
|
+
if path.is_dir():
|
|
67
|
+
path = path / ENTRY_FILE
|
|
68
|
+
try:
|
|
69
|
+
text = path.read_text(encoding="utf-8")
|
|
70
|
+
except (OSError, UnicodeDecodeError):
|
|
71
|
+
return []
|
|
72
|
+
block = parse_inline_metadata(text).get("script") or {}
|
|
73
|
+
raw = block.get("dependencies")
|
|
74
|
+
return [str(item) for item in raw] if isinstance(raw, list) else []
|
qi_agent/presets.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""预置 provider(国产为主)+ 它们的模型清单。
|
|
2
|
+
|
|
3
|
+
**两种用法,一套数据**:
|
|
4
|
+
|
|
5
|
+
* **兜底**(默认行为):`models.json` 里**没写**的预置 provider,由 `config.load_config`
|
|
6
|
+
自动补上(`with_preset_providers`)—— 所以 `export DEEPSEEK_API_KEY=…` 或
|
|
7
|
+
`qi auth login deepseek` 之后**立刻能用**,不必先物化配置文件;TUI 的 `/login` 也直接
|
|
8
|
+
看得到这批。
|
|
9
|
+
* **物化**(显式):`qi init --preset deepseek` 把它们**写进** `models.json`,方便改
|
|
10
|
+
(改成自建代理域名 / 加模型 / 调 `contextWindow`)。
|
|
11
|
+
|
|
12
|
+
**models.json 里写了同名 provider 就以它为准** —— 预置从不覆盖用户写的东西。
|
|
13
|
+
|
|
14
|
+
## 数据口径(重要)
|
|
15
|
+
|
|
16
|
+
表里每个模型都带 **`contextWindow`(上下文窗口=最大输入)与 `maxTokens`(最大输出)**
|
|
17
|
+
—— 这正是 pi `docs/models.md` 里这两个字段的语义,且**两个数都从厂商自己的文档/模型页
|
|
18
|
+
核过**(见每条注释里的出处)。核不到的模型**干脆不收**:预置宁缺毋滥 —— 猜一个
|
|
19
|
+
`maxTokens` 会让请求要么被拒要么悄悄截断,比没有这一条更糟。想补别的模型就直接改
|
|
20
|
+
`models.json`(`qi init --preset <名>` 先物化,再按厂商文档加一条)。
|
|
21
|
+
|
|
22
|
+
`reasoning` 一律留 False:这些模型多数**默认就带思考**,而 `reasoning_effort` 是不是被
|
|
23
|
+
各家 OpenAI 兼容端点接受并不一致 —— 默认不带参最不容易 400,想开就在 `models.json` 里
|
|
24
|
+
给该模型加 `"reasoning": true`(provider 拒收时 qi 会自动去掉并提示一次)。
|
|
25
|
+
|
|
26
|
+
## 种子会过时 —— 以接口为准
|
|
27
|
+
|
|
28
|
+
模型 id 会漂(上新 / 下线 / 改名)。本表只是**离线可用的种子**;要跟厂商对齐就用:
|
|
29
|
+
|
|
30
|
+
qi init --refresh <provider> # GET {baseUrl}/models,把实际可用的 id 并进 models.json
|
|
31
|
+
qi init --refresh-all # 所有已配置(有凭证)的 provider
|
|
32
|
+
|
|
33
|
+
交互流里同一条入口:`qi init` → `--- Add Models ---` 菜单选
|
|
34
|
+
`↻ Refresh model list (GET /models)`(与 `+ Add a model` 同级,失败只报错不中断)。
|
|
35
|
+
|
|
36
|
+
`refresh` **只加不删**:接口回来的新 id 追加进去(带 qi 的默认 ctx/max,厂商文档里有就自己补),
|
|
37
|
+
本地有、接口没返回的**保留并报出来**(厂商可能只是没列全,不该替用户删配置)。
|
|
38
|
+
|
|
39
|
+
实例:DeepSeek 从 `deepseek-v4-flash` 迁到 `deepseek-flash`(旧名还在但路由到新模型)——
|
|
40
|
+
这种变更只能靠接口/公告,写死的表迟早落后一拍。
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
DEFAULT_API = "openai-completions"
|
|
49
|
+
|
|
50
|
+
#: 几条常见的“取整”写法换算成 token 数(厂商文档常写 128K / 1M 这种)。
|
|
51
|
+
K = 1024
|
|
52
|
+
M = 1024 * 1024
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PresetModel:
|
|
57
|
+
"""一个预置模型条目。
|
|
58
|
+
|
|
59
|
+
`context_window` = 上下文窗口(最大输入),`max_output` = 模型的最大输出 tokens
|
|
60
|
+
(pi 的 `maxTokens` 字段同义)。两个数都必须有出处 —— 见模块 docstring 的口径。
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
id: str
|
|
64
|
+
context_window: int
|
|
65
|
+
max_output: int
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Preset:
|
|
70
|
+
"""一个预置 provider。"""
|
|
71
|
+
|
|
72
|
+
provider: str # provider 名(`provider/模型` 里那个)
|
|
73
|
+
label: str # 人看的名字
|
|
74
|
+
base_url: str
|
|
75
|
+
api_key_env: str # 约定环境变量名(与 auth.DEFAULT_API_KEY_ENV 一致)
|
|
76
|
+
models: tuple[PresetModel, ...] = field(default_factory=tuple)
|
|
77
|
+
api: str = DEFAULT_API
|
|
78
|
+
note: str = "" # 需要提醒用户的话(写进 `qi init --preset` 的输出)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def default_model(self) -> str | None:
|
|
82
|
+
"""物化后默认用哪个模型(取第一个)。"""
|
|
83
|
+
return self.models[0].id if self.models else None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
PRESETS: dict[str, Preset] = {
|
|
87
|
+
"deepseek": Preset(
|
|
88
|
+
provider="deepseek",
|
|
89
|
+
label="DeepSeek(深度求索)",
|
|
90
|
+
base_url="https://api.deepseek.com",
|
|
91
|
+
api_key_env="DEEPSEEK_API_KEY",
|
|
92
|
+
# 官方「模型 & 价格」(2026-09 核对):当前模型名是 `deepseek-flash`
|
|
93
|
+
# (= DeepSeek-V4.1-Flash,1M 上下文 / 输出最大 384K = 393216);
|
|
94
|
+
# `deepseek-v4-flash` 是**旧名**(底层已下线,请求被路由到 V4.1-Flash),
|
|
95
|
+
# `deepseek-v4-pro` 仍在但官方计划下线 —— 所以以 `deepseek-flash` 为首选。
|
|
96
|
+
# 觉得跟不上了就 `qi init --refresh deepseek`(以接口为准)。
|
|
97
|
+
models=(PresetModel("deepseek-flash", 1_000_000, 393_216),
|
|
98
|
+
PresetModel("deepseek-v4-pro", 1_000_000, 393_216)),
|
|
99
|
+
),
|
|
100
|
+
"dashscope": Preset(
|
|
101
|
+
provider="dashscope",
|
|
102
|
+
label="阿里云百炼(通义千问)",
|
|
103
|
+
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
104
|
+
api_key_env="DASHSCOPE_API_KEY",
|
|
105
|
+
# 百炼模型页(逐个核过):三者都是 上下文 1000000 / 最大输出 131072。
|
|
106
|
+
# 官方选型建议:`qwen3.7-plus` 能力与成本均衡、工具调用完整,是编码/Agent 场景的
|
|
107
|
+
# “多面手”(排第一 = 默认);要最强推理用 `qwen3.8-max`,要更省用 `qwen3.8-flash`。
|
|
108
|
+
models=(PresetModel("qwen3.7-plus", 1_000_000, 131_072),
|
|
109
|
+
PresetModel("qwen3.8-max", 1_000_000, 131_072),
|
|
110
|
+
PresetModel("qwen3.8-flash", 1_000_000, 131_072)),
|
|
111
|
+
note="北京地域新账号可能要用工作空间专属域名:"
|
|
112
|
+
"https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1",
|
|
113
|
+
),
|
|
114
|
+
"mimo": Preset(
|
|
115
|
+
provider="mimo",
|
|
116
|
+
label="小米 MiMo",
|
|
117
|
+
base_url="https://api.xiaomimimo.com/v1",
|
|
118
|
+
api_key_env="MIMO_API_KEY",
|
|
119
|
+
# MiMo 模型页 + list-models(2026-09 核):现役文本模型是 v2.6 系列,
|
|
120
|
+
# 上下文 1M(=1048576)/ 最大输出 128K(=131072);`flash` 官方定位“高智能、
|
|
121
|
+
# 低成本”(默认),`pro` 是旗舰。**旧的 `mimo-v2.5` / `mimo-v2.5-pro` 官方公告
|
|
122
|
+
# 2026-10-21 10:00 下线**(建议切新版),所以种子只放 v2.6 —— 下线公告:
|
|
123
|
+
# https://mimo.mi.com/docs/zh-CN/quick-start/summary/model
|
|
124
|
+
# `mimo-v2.6-pro-ultraspeed` = pro 的**速度档**(官方定位强实时场景,限流是
|
|
125
|
+
# “定制服务,联系平台”),同样 1M / 128K —— id、规格都核到了,一并收进种子。
|
|
126
|
+
models=(PresetModel("mimo-v2.6-flash", 1_048_576, 131_072),
|
|
127
|
+
PresetModel("mimo-v2.6-pro", 1_048_576, 131_072),
|
|
128
|
+
PresetModel("mimo-v2.6-pro-ultraspeed", 1_048_576, 131_072)),
|
|
129
|
+
note="订阅套餐(Token Plan)是另一个域名:`https://token-plan-cn.xiaomimimo.com/v1`,"
|
|
130
|
+
"Key 形如 `tp-…`(按量付费的 Key 形如 `sk-…`,两者不可混用);"
|
|
131
|
+
"`mimo-v2.5` / `mimo-v2.5-pro` 官方公告 2026-10-21 10:00 下线,"
|
|
132
|
+
"新模型是 `mimo-v2.6-*`(`qi init --refresh mimo` 拉全量);"
|
|
133
|
+
"其中 `mimo-v2.6-pro-ultraspeed` 是速度档,限流为定制服务(调前先问平台)",
|
|
134
|
+
),
|
|
135
|
+
"moonshot": Preset(
|
|
136
|
+
provider="moonshot",
|
|
137
|
+
label="月之暗面 Kimi",
|
|
138
|
+
base_url="https://api.moonshot.cn/v1",
|
|
139
|
+
api_key_env="MOONSHOT_API_KEY",
|
|
140
|
+
# Kimi 文档:k3 上下文 1M、max_completion_tokens 上限 131072;
|
|
141
|
+
# k2.7-code 上下文 256K、max_tokens 默认 32768
|
|
142
|
+
models=(PresetModel("kimi-k3", 1_048_576, 131_072),
|
|
143
|
+
PresetModel("kimi-k2.7-code", 262_144, 32_768)),
|
|
144
|
+
),
|
|
145
|
+
"zhipu": Preset(
|
|
146
|
+
provider="zhipu",
|
|
147
|
+
label="智谱 GLM",
|
|
148
|
+
base_url="https://open.bigmodel.cn/api/paas/v4",
|
|
149
|
+
api_key_env="ZHIPUAI_API_KEY",
|
|
150
|
+
# 智谱「模型概览」(2026-09 核):线上旗舰是 **GLM-5.2**(1M 上下文 / 最大输出 128K);
|
|
151
|
+
# GLM-5.3 官方页面写的是“API 即将上线”,所以不放进种子(上线后
|
|
152
|
+
# `qi init --refresh zhipu` 就能拉回来)。GLM-4.7:200K / 128K。
|
|
153
|
+
models=(PresetModel("glm-5.2", 1_048_576, 131_072),
|
|
154
|
+
PresetModel("glm-4.7", 202_752, 131_072)),
|
|
155
|
+
note="GLM-5.3 官方标注“API 即将上线”(Bailian 已可调);上线后 `qi init --refresh zhipu` 拉一下",
|
|
156
|
+
),
|
|
157
|
+
"minimax": Preset(
|
|
158
|
+
provider="minimax",
|
|
159
|
+
label="MiniMax",
|
|
160
|
+
base_url="https://api.minimax.cn/v1",
|
|
161
|
+
api_key_env="MINIMAX_API_KEY",
|
|
162
|
+
# MiniMax 文档:上下文 1,000,000;最大输出 128K
|
|
163
|
+
models=(PresetModel("MiniMax-M3", 1_000_000, 131_072),),
|
|
164
|
+
),
|
|
165
|
+
"siliconflow": Preset(
|
|
166
|
+
provider="siliconflow",
|
|
167
|
+
label="硅基流动(SiliconFlow)",
|
|
168
|
+
base_url="https://api.siliconflow.cn/v1",
|
|
169
|
+
api_key_env="SILICONFLOW_API_KEY",
|
|
170
|
+
# 硅基流动模型页:1M 上下文 + 最高 384K 输出
|
|
171
|
+
models=(PresetModel("deepseek-ai/DeepSeek-V4-Flash", 1_000_000, 393_216),),
|
|
172
|
+
note="聚合平台:模型 id 带组织前缀(`zai-org/GLM-5.2`、`Qwen/…`、`Pro/…`),"
|
|
173
|
+
"各家新模型在这儿的 id 与官方不同 —— 以模型广场/`qi init --refresh siliconflow` 为准",
|
|
174
|
+
),
|
|
175
|
+
"stepfun": Preset(
|
|
176
|
+
provider="stepfun",
|
|
177
|
+
label="阶跃星辰 StepFun",
|
|
178
|
+
base_url="https://api.stepfun.com/v1",
|
|
179
|
+
api_key_env="STEP_API_KEY",
|
|
180
|
+
# 阶跃/百炼模型页:上下文 262144 / 最大输出 262144
|
|
181
|
+
models=(PresetModel("step-3.7-flash", 262_144, 262_144),),
|
|
182
|
+
),
|
|
183
|
+
"hunyuan": Preset(
|
|
184
|
+
provider="hunyuan",
|
|
185
|
+
label="腾讯混元(TokenHub)",
|
|
186
|
+
base_url="https://tokenhub.tencentmaas.com/v1",
|
|
187
|
+
api_key_env="HUNYUAN_API_KEY",
|
|
188
|
+
# 腾讯已把混元迁到 **TokenHub**(旧平台 `api.hunyuan.cloud.tencent.com` 不再新增模型)。
|
|
189
|
+
# TokenHub 模型列表(2026-09 核):`hy4-preview` 上下文 1M / 最大输出 64K;
|
|
190
|
+
# `hy3` 上下文 256K / 最大输出 128K。
|
|
191
|
+
models=(PresetModel("hy4-preview", 1_048_576, 65_536),
|
|
192
|
+
PresetModel("hy3", 262_144, 131_072)),
|
|
193
|
+
note="这里是 TokenHub(新平台,Key 也从 TokenHub 控制台拿);"
|
|
194
|
+
"旧平台仍是 `https://api.hunyuan.cloud.tencent.com/v1` + `hunyuan-turbos-latest`,"
|
|
195
|
+
"两边的 Key 不通用",
|
|
196
|
+
),
|
|
197
|
+
"volcengine": Preset(
|
|
198
|
+
provider="volcengine",
|
|
199
|
+
label="火山方舟(豆包)",
|
|
200
|
+
base_url="https://ark.cn-beijing.volces.com/api/v3",
|
|
201
|
+
api_key_env="ARK_API_KEY",
|
|
202
|
+
# 厂商产品页(2026-09 核):上下文 256K(=262144)。最大输出两处口径不一(产品页写 256K、
|
|
203
|
+
# 文档写“单次最大输出 128K(含思考)”)—— **取小的那侧**(要 128K 之上的输出极少见,
|
|
204
|
+
# 而报一个超出上限的值会被接口打回)。
|
|
205
|
+
# `doubao-seed-evolving` 是**版本无关**的统一 id(周更自动迭代,不用改配置),所以排第一。
|
|
206
|
+
models=(PresetModel("doubao-seed-evolving", 262_144, 131_072),
|
|
207
|
+
PresetModel("doubao-seed-2-1-pro-260628", 262_144, 131_072)),
|
|
208
|
+
note="方舟的普通模型 id 带日期后缀(会变);`doubao-seed-evolving` 是统一 id 不需要换。"
|
|
209
|
+
"自定义部署还要用 Endpoint ID —— 以控制台“模型列表”里的调用名为准",
|
|
210
|
+
),
|
|
211
|
+
"qianfan": Preset(
|
|
212
|
+
provider="qianfan",
|
|
213
|
+
label="百度千帆(文心)",
|
|
214
|
+
base_url="https://qianfan.baidubce.com/v2",
|
|
215
|
+
api_key_env="QIANFAN_API_KEY",
|
|
216
|
+
# 千帆模型列表:上下文 128k(=131072) / 最大输出 65536
|
|
217
|
+
# 千帆模型列表:`ernie-5.1` 是文心系列**最新**模型,与 `ernie-5.0` 同为
|
|
218
|
+
# 上下文 128k(=131072) / 最大输出 65536
|
|
219
|
+
models=(PresetModel("ernie-5.1", 131_072, 65_536),
|
|
220
|
+
PresetModel("ernie-5.0", 131_072, 65_536)),
|
|
221
|
+
),
|
|
222
|
+
}
|
|
223
|
+
"""预置表(键 = `--preset` 收的名字,也是默认 provider 名)。"""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def preset_names() -> list[str]:
|
|
227
|
+
"""全部预置名(排序,给 `--list-presets` 与补全用)。"""
|
|
228
|
+
return sorted(PRESETS)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def get_preset(name: str) -> Preset | None:
|
|
232
|
+
"""按名字取预置(大小写不敏感;认 provider 名本身)。"""
|
|
233
|
+
key = (name or "").strip().lower()
|
|
234
|
+
if key in PRESETS:
|
|
235
|
+
return PRESETS[key]
|
|
236
|
+
for preset in PRESETS.values(): # 也认 label 里的 provider 名
|
|
237
|
+
if preset.provider.lower() == key:
|
|
238
|
+
return preset
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def provider_entry(preset: Preset) -> dict[str, Any]:
|
|
243
|
+
"""预置 → `models.json` 里的 provider 段(不含 models,便于合并时分开处理)。"""
|
|
244
|
+
return {
|
|
245
|
+
"api": preset.api,
|
|
246
|
+
"baseUrl": preset.base_url,
|
|
247
|
+
"apiKey": f"${preset.api_key_env}",
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def model_entry(model: PresetModel) -> dict[str, Any]:
|
|
252
|
+
"""预置模型 → `models.json` 的模型条目(`contextWindow` + `maxTokens` 都是核过的值)。"""
|
|
253
|
+
return {
|
|
254
|
+
"id": model.id,
|
|
255
|
+
"contextWindow": model.context_window,
|
|
256
|
+
"maxTokens": model.max_output,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def apply_presets(data: dict[str, Any], names: list[str]) -> tuple[dict[str, Any], list[str]]:
|
|
261
|
+
"""把预置合并进 `models.json` 的内容(返回 `(新内容, 变更说明)`)。
|
|
262
|
+
|
|
263
|
+
合并规矩(与 `qi init` 的其它路径一致:**不静默覆盖用户写过的东西**):
|
|
264
|
+
|
|
265
|
+
* provider 段:缺什么补什么(`api` / `baseUrl` / `apiKey`),已有值**不动** ——
|
|
266
|
+
用户改成自建代理或别的 key 来源之后,再跑一次 preset 不该把它洗掉;
|
|
267
|
+
* 模型列表:按 id 追加缺的;同一个 id 已存在就**原样保留**(不覆盖 reasoning /
|
|
268
|
+
contextWindow —— 用户手调过的以用户为准)。
|
|
269
|
+
|
|
270
|
+
`data` 会被原地修改(调用方拿到的就是它),同时返回便于链式使用。
|
|
271
|
+
"""
|
|
272
|
+
providers: dict[str, Any] = data.setdefault("providers", {})
|
|
273
|
+
changed: list[str] = []
|
|
274
|
+
for name in names:
|
|
275
|
+
preset = get_preset(name)
|
|
276
|
+
if preset is None:
|
|
277
|
+
raise KeyError(name)
|
|
278
|
+
raw_entry = providers.get(preset.provider)
|
|
279
|
+
entry: dict[str, Any] = raw_entry if isinstance(raw_entry, dict) else {}
|
|
280
|
+
if not isinstance(raw_entry, dict):
|
|
281
|
+
providers[preset.provider] = entry
|
|
282
|
+
for key, value in provider_entry(preset).items():
|
|
283
|
+
if not entry.get(key):
|
|
284
|
+
entry[key] = value
|
|
285
|
+
changed.append(f"{preset.provider}.{key}")
|
|
286
|
+
models: list[Any] = entry.setdefault("models", [])
|
|
287
|
+
known = {m.get("id") for m in models if isinstance(m, dict)}
|
|
288
|
+
for model in preset.models:
|
|
289
|
+
if model.id in known:
|
|
290
|
+
continue
|
|
291
|
+
models.append(model_entry(model))
|
|
292
|
+
changed.append(f"{preset.provider}/{model.id}")
|
|
293
|
+
return data, changed
|
qi_agent/prompt.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""交互式提示:上下键选择 + 文本输入(样式对齐 QwenPaw 的 questionary 体验)。
|
|
2
|
+
|
|
3
|
+
TTY 下用原始终端模式实现方向键菜单,渲染形如:
|
|
4
|
+
|
|
5
|
+
? 选择 provider
|
|
6
|
+
❯ my-llm [✓]
|
|
7
|
+
+ 新建 provider
|
|
8
|
+
|
|
9
|
+
选择后收敛为一行 ``? 选择 provider my-llm [✓]``。
|
|
10
|
+
非 TTY(管道 / 测试)退回编号输入。文本输入用 typer.prompt(行模式)。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
import typer
|
|
19
|
+
from rich.console import Console
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
_ESC = "\x1b"
|
|
24
|
+
_UP = ("\x1b[A", "\x1bOA")
|
|
25
|
+
_DOWN = ("\x1b[B", "\x1bOB")
|
|
26
|
+
_ENTER = ("\r", "\n")
|
|
27
|
+
_QUESTION = "\x1b[36m?\x1b[0m" # 青色 ?
|
|
28
|
+
_POINTER = "\x1b[36m❯\x1b[0m" # 青色指针
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_tty() -> bool:
|
|
32
|
+
try:
|
|
33
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
34
|
+
except Exception: # pragma: no cover - 极端环境
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_key() -> str:
|
|
39
|
+
"""读一个按键(TTY)。方向键返回带 ESC 的转义序列。"""
|
|
40
|
+
fd = sys.stdin.fileno()
|
|
41
|
+
try:
|
|
42
|
+
import termios
|
|
43
|
+
import tty
|
|
44
|
+
except ImportError: # Windows
|
|
45
|
+
import msvcrt
|
|
46
|
+
|
|
47
|
+
ch = msvcrt.getwch()
|
|
48
|
+
if ch in ("\x00", "\xe0"):
|
|
49
|
+
nxt = msvcrt.getwch()
|
|
50
|
+
return _ESC + {"H": "A", "P": "B"}.get(nxt, nxt)
|
|
51
|
+
return ch
|
|
52
|
+
|
|
53
|
+
old = termios.tcgetattr(fd)
|
|
54
|
+
try:
|
|
55
|
+
tty.setraw(fd)
|
|
56
|
+
ch = os.read(fd, 1).decode("utf-8", "ignore")
|
|
57
|
+
if ch == _ESC:
|
|
58
|
+
nxt = os.read(fd, 1).decode("utf-8", "ignore")
|
|
59
|
+
if nxt in ("[", "O"):
|
|
60
|
+
nxt += os.read(fd, 1).decode("utf-8", "ignore")
|
|
61
|
+
return ch + nxt
|
|
62
|
+
return ch
|
|
63
|
+
finally:
|
|
64
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _clear_lines(count: int) -> None:
|
|
68
|
+
sys.stdout.write(f"\x1b[{count}A")
|
|
69
|
+
for _ in range(count):
|
|
70
|
+
sys.stdout.write("\r\x1b[2K\n")
|
|
71
|
+
sys.stdout.write(f"\x1b[{count}A")
|
|
72
|
+
sys.stdout.flush()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def select(message: str, options: list[str], default: int = 0) -> int:
|
|
76
|
+
"""上下键选择;返回选中索引。样式对齐 questionary。"""
|
|
77
|
+
if not options:
|
|
78
|
+
raise ValueError("options 不能为空")
|
|
79
|
+
default = max(0, min(default, len(options) - 1))
|
|
80
|
+
if not _is_tty():
|
|
81
|
+
return _numbered(message, options, default)
|
|
82
|
+
|
|
83
|
+
idx = default
|
|
84
|
+
n = len(options)
|
|
85
|
+
sys.stdout.write(f"{_QUESTION} {message}\n")
|
|
86
|
+
|
|
87
|
+
def render() -> None:
|
|
88
|
+
for i, opt in enumerate(options):
|
|
89
|
+
if i == idx:
|
|
90
|
+
sys.stdout.write(f"\r\x1b[2K {_POINTER} {opt}\n")
|
|
91
|
+
else:
|
|
92
|
+
sys.stdout.write(f"\r\x1b[2K {opt}\n")
|
|
93
|
+
sys.stdout.flush()
|
|
94
|
+
|
|
95
|
+
render()
|
|
96
|
+
while True:
|
|
97
|
+
key = _read_key()
|
|
98
|
+
if key in _UP or key == "k":
|
|
99
|
+
idx = (idx - 1) % n
|
|
100
|
+
elif key in _DOWN or key == "j":
|
|
101
|
+
idx = (idx + 1) % n
|
|
102
|
+
elif key in _ENTER:
|
|
103
|
+
_clear_lines(n + 1)
|
|
104
|
+
sys.stdout.write(f"{_QUESTION} {message} \x1b[36m{options[idx]}\x1b[0m\n")
|
|
105
|
+
sys.stdout.flush()
|
|
106
|
+
return idx
|
|
107
|
+
elif key in ("\x03", "\x04"): # Ctrl-C / Ctrl-D
|
|
108
|
+
raise KeyboardInterrupt
|
|
109
|
+
else:
|
|
110
|
+
continue
|
|
111
|
+
sys.stdout.write(f"\x1b[{n}A")
|
|
112
|
+
render()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _numbered(message: str, options: list[str], default: int) -> int:
|
|
116
|
+
for i, opt in enumerate(options, 1):
|
|
117
|
+
pointer = "[cyan]❯[/cyan]" if i - 1 == default else " "
|
|
118
|
+
console.print(f" {pointer} [cyan]{i}[/cyan]) {opt}")
|
|
119
|
+
raw = typer.prompt(message, default=str(default + 1), show_default=True).strip()
|
|
120
|
+
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
|
121
|
+
return int(raw) - 1
|
|
122
|
+
if raw in options:
|
|
123
|
+
return options.index(raw)
|
|
124
|
+
return default
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def confirm(message: str, default: bool = True) -> bool:
|
|
128
|
+
"""是/否选择(上下键)。"""
|
|
129
|
+
return select(message, ["是", "否"], default=0 if default else 1) == 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def text(message: str, default: str | None = None, required: bool = False,
|
|
133
|
+
suffix: str = "") -> str:
|
|
134
|
+
"""文本输入;required=True 时不允许空值。"""
|
|
135
|
+
label = message + suffix
|
|
136
|
+
while True:
|
|
137
|
+
raw = typer.prompt(label, default=default or "", show_default=bool(default)).strip()
|
|
138
|
+
if raw or not required:
|
|
139
|
+
return raw
|
|
140
|
+
console.print("[yellow]该项必填。[/yellow]")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def integer(message: str, default: int) -> int:
|
|
144
|
+
"""整数输入,默认值可直接回车。"""
|
|
145
|
+
raw = typer.prompt(message, default=default)
|
|
146
|
+
try:
|
|
147
|
+
return int(raw)
|
|
148
|
+
except (TypeError, ValueError):
|
|
149
|
+
console.print("[yellow]请输入整数。[/yellow]")
|
|
150
|
+
return integer(message, default)
|