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/config.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
"""模型配置 `models.json`(对齐 pi 格式)+ 解析。
|
|
2
|
+
|
|
3
|
+
文件位置(优先级从高到低):
|
|
4
|
+
$QI_AGENT_CONFIG(指定文件)→ <项目>/.qi/models.json → ~/.qi/agent/models.json
|
|
5
|
+
合并:键级深合并,内层覆盖外层;providers 按 provider 名合并。
|
|
6
|
+
|
|
7
|
+
默认模型 `defaultProvider` / `defaultModel` **只属于 settings.json**(对齐 pi):
|
|
8
|
+
|
|
9
|
+
<项目>/.qi/settings.json > ~/.qi/agent/settings.json
|
|
10
|
+
|
|
11
|
+
models.json 里的这两个键**已不再被读取**(不是"优先级更低",是彻底不参与);
|
|
12
|
+
启动时若发现残留,报错/诊断里会给出可照抄的迁移命令。
|
|
13
|
+
|
|
14
|
+
格式与 pi 的 models.json 相同:
|
|
15
|
+
|
|
16
|
+
{
|
|
17
|
+
"providers": {
|
|
18
|
+
"deepseek": {
|
|
19
|
+
"baseUrl": "https://api.deepseek.com/v1",
|
|
20
|
+
"api": "openai-completions",
|
|
21
|
+
"apiKey": "$DEEPSEEK_API_KEY",
|
|
22
|
+
"models": [
|
|
23
|
+
{ "id": "deepseek-chat", "reasoning": false,
|
|
24
|
+
"contextWindow": 128000, "maxTokens": 16384 }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
凭证明文永不写入本文件:只允许 `apiKey` 引用(字面量/`$ENV`/`!command`)或 auth store。
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import contextlib
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from pydantic import BaseModel, Field
|
|
43
|
+
|
|
44
|
+
from .paths import MODELS_FILE_NAME, SETTINGS_FILE_NAME, models_file_candidates
|
|
45
|
+
from .settings import SettingsError, deep_merge, load_settings, read_json
|
|
46
|
+
|
|
47
|
+
# 支持 pi 的 api 类型(见 docs/models.md)
|
|
48
|
+
DEFAULT_API = "openai-completions"
|
|
49
|
+
SUPPORTED_APIS = (
|
|
50
|
+
"openai-completions",
|
|
51
|
+
"openai-responses",
|
|
52
|
+
"anthropic-messages",
|
|
53
|
+
"google-generative-ai",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
DEFAULT_CONTEXT_WINDOW = 128000
|
|
57
|
+
DEFAULT_MAX_TOKENS = 16384
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConfigError(Exception):
|
|
61
|
+
"""配置缺失/校验失败,启动即报(对应 qi doctor 的检查项)。"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ModelEntry(BaseModel):
|
|
65
|
+
"""pi 格式的单个模型定义(额外字段原样保留)。"""
|
|
66
|
+
|
|
67
|
+
model_config = {"extra": "allow"} # type: ignore[assignment]
|
|
68
|
+
|
|
69
|
+
id: str
|
|
70
|
+
name: str | None = None
|
|
71
|
+
api: str | None = None
|
|
72
|
+
reasoning: bool = False
|
|
73
|
+
input: list[str] = Field(default_factory=lambda: ["text"])
|
|
74
|
+
contextWindow: int = DEFAULT_CONTEXT_WINDOW
|
|
75
|
+
maxTokens: int = DEFAULT_MAX_TOKENS
|
|
76
|
+
cost: dict[str, Any] | None = None
|
|
77
|
+
headers: dict[str, str] | None = None
|
|
78
|
+
compat: dict[str, Any] | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ProviderConfig(BaseModel):
|
|
82
|
+
"""pi 格式的 provider 定义(额外字段原样保留)。"""
|
|
83
|
+
|
|
84
|
+
model_config = {"extra": "allow"} # type: ignore[assignment]
|
|
85
|
+
|
|
86
|
+
baseUrl: str | None = None
|
|
87
|
+
api: str | None = None
|
|
88
|
+
apiKey: str | None = None
|
|
89
|
+
headers: dict[str, str] = Field(default_factory=dict)
|
|
90
|
+
models: list[ModelEntry] = Field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class QiConfig(BaseModel):
|
|
94
|
+
"""models.json 的配置面。未知顶层段原样保留(extra=allow)。
|
|
95
|
+
|
|
96
|
+
注:`defaultProvider` / `defaultModel` **不在这里** —— 默认模型属于
|
|
97
|
+
`settings.json`(见 settings.QiSettings);models.json 里写了也会被剔除。
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
model_config = {"extra": "allow"} # type: ignore[assignment]
|
|
101
|
+
|
|
102
|
+
routerProvider: str | None = None
|
|
103
|
+
routerModel: str | None = None
|
|
104
|
+
providers: dict[str, ProviderConfig] = Field(default_factory=dict)
|
|
105
|
+
#: 哪些 provider 来自 **qi 的预置表**(`presets.py`)而不是用户的 models.json。
|
|
106
|
+
#: 由 `load_config` 填充,只给 UI / 诊断做标记用 —— models.json 里写了就以它为准。
|
|
107
|
+
presetProviders: set[str] = Field(default_factory=set)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class ResolvedModel:
|
|
112
|
+
"""解析后的运行期模型(provider 默认 + 模型条目合并)。"""
|
|
113
|
+
|
|
114
|
+
provider: str
|
|
115
|
+
model: str
|
|
116
|
+
api: str
|
|
117
|
+
base_url: str | None
|
|
118
|
+
api_key_ref: str | None
|
|
119
|
+
reasoning: bool
|
|
120
|
+
context_window: int
|
|
121
|
+
max_tokens: int
|
|
122
|
+
entry: ModelEntry | None = None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def label(self) -> str:
|
|
126
|
+
return f"{self.provider}/{self.model}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def deep_merge_config(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
130
|
+
"""兼容旧调用名;实现已统一到 settings.deep_merge。"""
|
|
131
|
+
return deep_merge(base, override)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
135
|
+
"""读取单个 JSON 文件;失败转 ConfigError(settings.read_json 报路径)。"""
|
|
136
|
+
try:
|
|
137
|
+
return read_json(path)
|
|
138
|
+
except SettingsError as exc:
|
|
139
|
+
raise ConfigError(str(exc)) from exc
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def load_raw(cwd: Path | None = None) -> tuple[dict[str, Any], list[Path]]:
|
|
143
|
+
"""按优先级装载并深合并;返回 (merged_dict, 实际读取的文件列表)。"""
|
|
144
|
+
merged: dict[str, Any] = {}
|
|
145
|
+
loaded: list[Path] = []
|
|
146
|
+
# 高优先级在前;从低到高合并,让高优先级覆盖
|
|
147
|
+
for path in reversed(models_file_candidates(cwd)):
|
|
148
|
+
if not path.is_file():
|
|
149
|
+
continue
|
|
150
|
+
merged = deep_merge(merged, _read_json(path))
|
|
151
|
+
loaded.append(path)
|
|
152
|
+
return merged, list(reversed(loaded))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def default_model_spec(cwd: Path | None = None) -> tuple[str | None, str | None, str]:
|
|
156
|
+
"""默认模型及其来源,供 `qi doctor` 诊断。
|
|
157
|
+
|
|
158
|
+
**只从 settings.json 读**(对齐 pi):
|
|
159
|
+
|
|
160
|
+
<项目>/.qi/settings.json > ~/.qi/agent/settings.json
|
|
161
|
+
|
|
162
|
+
`models.json` 里的 `defaultProvider` / `defaultModel` **不再被读取** ——
|
|
163
|
+
如果旧文件里还留着,`legacy_default_keys()` 会报出来。
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
settings, files = load_settings(cwd)
|
|
167
|
+
except SettingsError as exc:
|
|
168
|
+
raise ConfigError(str(exc)) from exc
|
|
169
|
+
if settings.defaultProvider and settings.defaultModel:
|
|
170
|
+
return settings.defaultProvider, settings.defaultModel, (
|
|
171
|
+
f"settings:{files[0]}" if files else "settings"
|
|
172
|
+
)
|
|
173
|
+
return None, None, "未配置"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def legacy_default_keys(cwd: Path | None = None) -> list[tuple[Path, str | None, str | None]]:
|
|
177
|
+
"""找出 models.json 里残留的 `defaultProvider`/`defaultModel`。
|
|
178
|
+
|
|
179
|
+
返回 `(文件, provider, model)`;空列表表示没有残留。这两个键已不再生效,
|
|
180
|
+
仅用于在报错/诊断里给出可照抄的迁移指令。
|
|
181
|
+
"""
|
|
182
|
+
hits: list[tuple[Path, str | None, str | None]] = []
|
|
183
|
+
for path in models_file_candidates(cwd):
|
|
184
|
+
if not path.is_file():
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
data = read_json(path)
|
|
188
|
+
except SettingsError:
|
|
189
|
+
continue
|
|
190
|
+
if "defaultProvider" not in data and "defaultModel" not in data:
|
|
191
|
+
continue
|
|
192
|
+
provider = data.get("defaultProvider")
|
|
193
|
+
model = data.get("defaultModel")
|
|
194
|
+
hits.append((
|
|
195
|
+
path,
|
|
196
|
+
str(provider) if provider else None,
|
|
197
|
+
str(model) if model else None,
|
|
198
|
+
))
|
|
199
|
+
return hits
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _legacy_hint(cwd: Path | None) -> str:
|
|
203
|
+
"""残留老的默认模型键时,拼一段可照抄的迁移提示。"""
|
|
204
|
+
try:
|
|
205
|
+
hits = legacy_default_keys(cwd)
|
|
206
|
+
except Exception: # noqa: BLE001 - 提示文本不该反过来炸掉启动
|
|
207
|
+
return ""
|
|
208
|
+
if not hits:
|
|
209
|
+
return ""
|
|
210
|
+
path, provider, model = hits[0]
|
|
211
|
+
cmd = "qi config"
|
|
212
|
+
if provider:
|
|
213
|
+
cmd += f" --set defaultProvider={provider}"
|
|
214
|
+
if model:
|
|
215
|
+
cmd += f" --set defaultModel={model}"
|
|
216
|
+
body = (
|
|
217
|
+
f"\n已从 {path} 读到 `defaultProvider`/`defaultModel`,但这两个键已不再从 "
|
|
218
|
+
f"{MODELS_FILE_NAME} 读取(默认模型属于 {SETTINGS_FILE_NAME})。\n"
|
|
219
|
+
f" {cmd}\n"
|
|
220
|
+
f"然后删掉 {path} 里的这两行。"
|
|
221
|
+
)
|
|
222
|
+
if len(hits) > 1:
|
|
223
|
+
body += "\n(另有残留:" + ", ".join(str(p) for p, _x, _y in hits[1:]) + ")"
|
|
224
|
+
return body
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def with_preset_providers(raw: dict[str, Any]) -> set[str]:
|
|
228
|
+
"""把**预置 provider** 补给 `models.json` 里没写到的那些(原地修改 `raw`)。
|
|
229
|
+
|
|
230
|
+
规矩一条:**models.json 里写了的以它为准**,预置只当兜底。
|
|
231
|
+
|
|
232
|
+
为什么要兜底:预置的意义就是“`qi auth login deepseek` 之后立刻能用”,而不是先要求用户去
|
|
233
|
+
跑一次 `qi init --preset`。所以 `deepseek` / `moonshot` 这些只要没在 models.json 里出现,
|
|
234
|
+
就按 `presets.py` 的定义补上(baseUrl / api / 约定环境变量的 apiKey 引用 / 模型清单)。
|
|
235
|
+
想改它们就 `qi init --preset <名>` 物化到 models.json 里再改(那时预置就不再插手)。
|
|
236
|
+
|
|
237
|
+
返回补进去的 provider 名(给 `QiConfig.presetProviders` 做标记)。
|
|
238
|
+
"""
|
|
239
|
+
from .presets import PRESETS, model_entry, provider_entry
|
|
240
|
+
|
|
241
|
+
providers: dict[str, Any] = raw.setdefault("providers", {})
|
|
242
|
+
added: set[str] = set()
|
|
243
|
+
for name, preset in PRESETS.items():
|
|
244
|
+
if isinstance(providers.get(name), dict):
|
|
245
|
+
continue # 用户写了这个 provider —— 以他为准
|
|
246
|
+
entry = provider_entry(preset)
|
|
247
|
+
entry["models"] = [model_entry(model) for model in preset.models]
|
|
248
|
+
providers[name] = entry
|
|
249
|
+
added.add(name)
|
|
250
|
+
return added
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def load_config(cwd: Path | None = None) -> tuple[QiConfig, list[Path]]:
|
|
254
|
+
"""装载 models.json(用户写的 + **预置兜底**,见 `with_preset_providers`)。
|
|
255
|
+
|
|
256
|
+
注:默认模型**不在**这里 —— 它属于 settings.json,
|
|
257
|
+
由 `resolve_default_model()` / `default_model_spec()` 单独解析。
|
|
258
|
+
"""
|
|
259
|
+
raw, loaded = load_raw(cwd)
|
|
260
|
+
# models.json 里的 default* 不再是有效字段:剔掉,避免被 pydantic 当成未知键静默收下
|
|
261
|
+
raw.pop("defaultProvider", None)
|
|
262
|
+
raw.pop("defaultModel", None)
|
|
263
|
+
preset_providers = with_preset_providers(raw)
|
|
264
|
+
try:
|
|
265
|
+
cfg = QiConfig.model_validate(raw)
|
|
266
|
+
except Exception as exc: # pydantic.ValidationError
|
|
267
|
+
raise ConfigError(f"配置校验失败: {exc}") from exc
|
|
268
|
+
cfg.presetProviders = preset_providers
|
|
269
|
+
return cfg, loaded
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def models_file_for_provider(provider: str, cwd: Path | None = None) -> Path | None:
|
|
273
|
+
"""哪个 `models.json` 文件定义了这个 provider(**优先级最高**的那个)。
|
|
274
|
+
|
|
275
|
+
给 `qi init --refresh` 用:刷新要写回**定义它的那个文件**,而不是无脑写用户级 ——
|
|
276
|
+
否则会在另一个文件里长出一份同名的新定义,而合并取高优先级的那个,于是
|
|
277
|
+
“刷新了却看不到效果”。哪个文件都没定义(预置兜底来的)才回落到用户级。
|
|
278
|
+
"""
|
|
279
|
+
for path in models_file_candidates(cwd):
|
|
280
|
+
if not path.is_file():
|
|
281
|
+
continue
|
|
282
|
+
try:
|
|
283
|
+
providers = read_json(path).get("providers")
|
|
284
|
+
except SettingsError:
|
|
285
|
+
continue
|
|
286
|
+
if isinstance(providers, dict) and provider in providers:
|
|
287
|
+
return path
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def in_play_providers(cfg: QiConfig, *, default_provider: str | None = None,
|
|
292
|
+
router_provider: str | None = None) -> set[str]:
|
|
293
|
+
"""“在用”的 provider:`models.json` 里显式写过的 + 默认 / 路由模型指向的那个。
|
|
294
|
+
|
|
295
|
+
用来做**“缺凭证”的告警口径**:预置兜底那批只是目录(没配、也没被选为默认),
|
|
296
|
+
不该算“缺凭证” —— 否则预置有十家、“凭证全部就绪”永远不可能成立。
|
|
297
|
+
但被选为默认模型的预置 provider **必须算** —— 不然用户看不出自己缺哪把钥匙。
|
|
298
|
+
"""
|
|
299
|
+
explicit = set(cfg.providers) - set(cfg.presetProviders)
|
|
300
|
+
return explicit | {name for name in (default_provider, router_provider) if name}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def selectable_models(cfg: QiConfig, *, store: Any = None) -> list[tuple[str, str]]:
|
|
304
|
+
"""**可选模型**清单(`provider, model_id`)—— `/model` / ctrl+l·p 轮换 / `/scoped-models` /
|
|
305
|
+
web 的模型菜单 / `qi --list-models` 共用这一份。
|
|
306
|
+
|
|
307
|
+
口径一条:**解析得出凭证的 provider 才列**(`resolve_key(...).ok`)—— auth store →
|
|
308
|
+
约定环境变量 → `models.json` 里的 `apiKey` 引用(含 `$(命令)`),三路任一命中即可;
|
|
309
|
+
`ollama` 这类本来就不需要密钥的在 `KEYLESS_PROVIDERS` 里,照样算命中。
|
|
310
|
+
|
|
311
|
+
与 pi 对齐:pi 的清单是 `modelRuntime.getAvailableSnapshot()`,而它由
|
|
312
|
+
`Models.getAvailable()` → `checkProviderAuth()` 产出 —— **没有凭证的 provider
|
|
313
|
+
一个模型都不进选择器**(pi 只提示 “Only showing models from configured providers.
|
|
314
|
+
Use /login to add providers.”)。`models.json` 里显式写过的 provider **没有豁免**:
|
|
315
|
+
写了 `apiKey` 却解析不出来(环境变量没导出、`$(…)` 跑不通),那就是“用不了”,
|
|
316
|
+
不该在选择器里冒充可选 —— 用户的口径是“我把 mimo logout 了,它怎么还在”,
|
|
317
|
+
而 `qi doctor` / `--list-models` 仍然会把“缺密钥”列出来(那两个面就是用来看配置的)。
|
|
318
|
+
|
|
319
|
+
返回按 provider、再按模型 id 排序(界面直接照着画,不需要各自再排一遍)。
|
|
320
|
+
|
|
321
|
+
`store` 是凭证库(默认真的那个)。之所以收 `Any` 而不是 `AuthStore`:这个模块
|
|
322
|
+
不 import `auth`(auth 的 `apiKey` 引用语法在本模块解析,反向 import 会成环),
|
|
323
|
+
而调用方传进来的可能是 `AuthOverride`。
|
|
324
|
+
"""
|
|
325
|
+
if store is None:
|
|
326
|
+
from .auth import AuthStore # 局部导入:只在真的要判凭证时用
|
|
327
|
+
store = AuthStore()
|
|
328
|
+
from .auth import resolve_key
|
|
329
|
+
out: list[tuple[str, str]] = []
|
|
330
|
+
for provider, prov in sorted((cfg.providers or {}).items()):
|
|
331
|
+
if not resolve_key(provider, prov.apiKey, store).ok:
|
|
332
|
+
continue
|
|
333
|
+
for entry in prov.models:
|
|
334
|
+
out.append((provider, entry.id))
|
|
335
|
+
return out
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def resolve_model(cfg: QiConfig, provider: str, model: str) -> ResolvedModel:
|
|
339
|
+
"""按 provider 默认 + 模型条目合并出运行期模型(模型条目可缺省)。"""
|
|
340
|
+
prov = cfg.providers.get(provider)
|
|
341
|
+
entry: ModelEntry | None = None
|
|
342
|
+
if prov is not None:
|
|
343
|
+
for m in prov.models:
|
|
344
|
+
if m.id == model:
|
|
345
|
+
entry = m
|
|
346
|
+
break
|
|
347
|
+
api = (entry.api if entry and entry.api else None) or (prov.api if prov else None) or DEFAULT_API
|
|
348
|
+
return ResolvedModel(
|
|
349
|
+
provider=provider,
|
|
350
|
+
model=model,
|
|
351
|
+
api=api,
|
|
352
|
+
base_url=prov.baseUrl if prov else None,
|
|
353
|
+
api_key_ref=prov.apiKey if prov else None,
|
|
354
|
+
reasoning=entry.reasoning if entry else False,
|
|
355
|
+
context_window=entry.contextWindow if entry else DEFAULT_CONTEXT_WINDOW,
|
|
356
|
+
max_tokens=entry.maxTokens if entry else DEFAULT_MAX_TOKENS,
|
|
357
|
+
entry=entry,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def resolve_default_model(cfg: QiConfig, cwd: Path | None = None) -> ResolvedModel:
|
|
362
|
+
"""解析默认模型;未配置则启动失败(带可照抄的指引)。
|
|
363
|
+
|
|
364
|
+
默认模型只从 `settings.json` 读(对齐 pi),`models.json` 不参与。
|
|
365
|
+
"""
|
|
366
|
+
provider, model, _source = default_model_spec(cwd)
|
|
367
|
+
if not provider or not model:
|
|
368
|
+
raise ConfigError(
|
|
369
|
+
f"缺少默认模型:{SETTINGS_FILE_NAME} 里需要 defaultProvider + defaultModel。\n"
|
|
370
|
+
" qi config --set defaultProvider=<name> --set defaultModel=<id>\n"
|
|
371
|
+
" (或 `qi init` 引导写入;`qi init --preset deepseek` 一条命令连 provider + 默认模型都写好)\n"
|
|
372
|
+
" (预置 provider:deepseek / dashscope / moonshot / zhipu … 见 `qi init --list-presets`)\n"
|
|
373
|
+
f" ~/.qi/agent/{SETTINGS_FILE_NAME} ← 默认模型(对齐 pi)\n"
|
|
374
|
+
f" ~/.qi/agent/{MODELS_FILE_NAME} ← 只放 provider / 模型定义\n"
|
|
375
|
+
"示例:\n"
|
|
376
|
+
" {\n"
|
|
377
|
+
' "defaultProvider": "deepseek",\n'
|
|
378
|
+
' "defaultModel": "deepseek-chat"\n'
|
|
379
|
+
" }\n"
|
|
380
|
+
+ _legacy_hint(cwd)
|
|
381
|
+
)
|
|
382
|
+
return resolve_model(cfg, provider, model)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def resolve_router_model(cfg: QiConfig, cwd: Path | None = None) -> ResolvedModel:
|
|
386
|
+
"""router 模型;未单独配置则回退 default。"""
|
|
387
|
+
if cfg.routerProvider and cfg.routerModel:
|
|
388
|
+
return resolve_model(cfg, cfg.routerProvider, cfg.routerModel)
|
|
389
|
+
return resolve_default_model(cfg, cwd)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ── 读写原始 models.json(qi init 用) ─────────────────────
|
|
393
|
+
|
|
394
|
+
def load_models_file(path: Path) -> dict[str, Any]:
|
|
395
|
+
"""读取单个 models.json(不存在/损坏返回 {})。"""
|
|
396
|
+
if not path.is_file():
|
|
397
|
+
return {}
|
|
398
|
+
try:
|
|
399
|
+
return _read_json(path)
|
|
400
|
+
except (OSError, json.JSONDecodeError):
|
|
401
|
+
return {}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def save_models_file(path: Path, data: dict[str, Any]) -> None:
|
|
405
|
+
"""写入单个 models.json(0600;虽只存引用,也按敏感文件处理)。"""
|
|
406
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
408
|
+
with contextlib.suppress(OSError): # Windows 无 posix chmod
|
|
409
|
+
os.chmod(path, 0o600)
|
qi_agent/docs/cli.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# 命令行
|
|
2
|
+
|
|
3
|
+
qi 的内置命令与选项。装了什么扩展,顶层就还会多出它们注册的旗标与子命令 —— 以 `qi --help` 为准。
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
qi [options] [--] [@files...] [messages...]
|
|
7
|
+
qi install <来源> [options]
|
|
8
|
+
qi remove <来源> [options]
|
|
9
|
+
qi uninstall <来源> [options]
|
|
10
|
+
qi update [来源|self] [options]
|
|
11
|
+
qi list
|
|
12
|
+
qi doctor
|
|
13
|
+
qi config [options]
|
|
14
|
+
qi init [options]
|
|
15
|
+
qi auth <login|logout|list|print-api-key|print-bearer-token|check> [options]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 调用与输出
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
qi # 交互界面(默认)
|
|
22
|
+
qi "分析这个仓库" # 进界面,并把这句话作为首条消息发出
|
|
23
|
+
qi -p "把 CHANGELOG 最近 10 条汇总" # 无头:只把最终答案写到 stdout
|
|
24
|
+
git diff | qi -p "审查这个改动" # 管道进来的内容会加到第一条消息前面
|
|
25
|
+
qi --mode json "跑一遍测试" > e.jsonl # 事件流
|
|
26
|
+
qi --export out.html # 导出会话后退出
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| 输入 | 行为 |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `message` | 作为第一条消息 |
|
|
32
|
+
| `@path` | 把文本文件内容带进第一条消息 |
|
|
33
|
+
| 管道 stdin | 内容**前置**到第一条消息 |
|
|
34
|
+
| `--` | 停止选项解析(消息本身以 `-` 开头时用) |
|
|
35
|
+
|
|
36
|
+
| 选项 | 行为 |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `-p`, `--print` | 跑完退出,**只把最终答案写 stdout** |
|
|
39
|
+
| `--mode text` | 文本输出;**stdin/stdout 都是终端时仍进界面**(要一次执行就配 `-p`) |
|
|
40
|
+
| `--mode json` | 输出事件流(一行一个 JSON,**隐含无头**)→ [json.md](json.md) |
|
|
41
|
+
| `--mode rpc` | **未实现**:传了会明确报错(见 [PLAN.md](../design/PLAN.md) 的 v2 清单) |
|
|
42
|
+
| `--export <file>` | 导出会话:`.html` → 自包含 HTML,其余 → 原始 JSONL |
|
|
43
|
+
|
|
44
|
+
**选项写在消息之前**(`qi -nt "问题"`,不是 `qi "问题" -nt`):顶层选项在遇到消息后不再解析 —— 写反了会报错并提示怎么改。
|
|
45
|
+
|
|
46
|
+
### 输出与中断契约
|
|
47
|
+
|
|
48
|
+
| 流 | 内容 |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| stdout | **仅答案正文**(`--mode json` 时为事件 JSON 行) |
|
|
51
|
+
| stderr | 默认空;`--verbose` 时为工具进度(`⚙ ls {"path": "."}` / `↳ …`) |
|
|
52
|
+
|
|
53
|
+
所以 `qi -p "问题" > out.txt` 拿到的就是纯答案,脚本无需过滤;TUI 不受此限,仍实时显示工具调用。
|
|
54
|
+
|
|
55
|
+
轮次**不设上限**(交互式与无头都是):退出靠模型不再调工具、中止,或嵌入方的 `stop_after` 谓词(qi 自己从不传)。
|
|
56
|
+
中断退出码 `128+n`:`SIGTERM` / `SIGHUP` → 回收在跑的子进程组 + `exit(143/129)`;`SIGINT` → asyncio 取消 →
|
|
57
|
+
`run_shell` 的 finally 回收 + `exit(130)`,不打 traceback。两条路径的半截回答都会落盘。
|
|
58
|
+
|
|
59
|
+
## 模型
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
qi --model sonnet:high
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
选择与凭证见 [models.md](models.md)、[providers.md](providers.md)。
|
|
66
|
+
|
|
67
|
+
- `--provider <名>`:把 `--model` 的查找限制在一家。
|
|
68
|
+
- `--model <模式>`:写 `provider/模型`,可带 `:<思考级别>` 后缀(**只认已知级别**,所以 `openrouter/x:free` 不会被误切);只给 `--provider` 时用它 `models.json` 里的第一个模型。
|
|
69
|
+
- `--api-key <键>`:**按次**覆盖密钥,不落盘(优先于 auth store / 环境变量 / `models.json`)。
|
|
70
|
+
- `--thinking <级别>`:`off` / `minimal` / `low` / `medium` / `high` / `xhigh` / `max`(非法值退出码 2;不传用 settings 的 `defaultThinkingLevel`,没配就 `medium`)。
|
|
71
|
+
- `--models <清单>`:本次运行 `ctrl+p` 的轮换清单(逗号分隔),**不回写** settings。
|
|
72
|
+
- `--list-models [搜索词]`:列出模型后退出(每行带 `credential` 列;缺密钥的也列,这是诊断面)。
|
|
73
|
+
|
|
74
|
+
## 会话
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
qi -c
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
细节见 [sessions.md](sessions.md)。
|
|
81
|
+
|
|
82
|
+
- `-c`, `--continue`:接着最近一条(按**文件修改时间**,不是 header 时间)。
|
|
83
|
+
- `-r`, `--resume`:打开会话选择器(**需要 TTY**;与 TUI 的 `/resume` 同一条路)。
|
|
84
|
+
- `--session <路径|id>`:先当文件路径,再当 id / 文件名前缀。
|
|
85
|
+
- `--session-id <id>`:精确的项目会话 id,不存在则建。
|
|
86
|
+
- `--fork <路径|id>`:从已有会话分叉出新会话(复制当前分支)。
|
|
87
|
+
- `--session-dir <dir>`:会话目录(优先于 `settings.sessionDir`)。
|
|
88
|
+
- `--no-session`:内存会话,**不落盘**。
|
|
89
|
+
- `-n`, `--name <名>`:会话显示名(命名即落盘)。
|
|
90
|
+
|
|
91
|
+
## 工具
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
qi --tools read,grep,find,ls -p "审查这个项目"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
内置工具清单与三态语义见 [how-qi-works.md](how-qi-works.md);默认档在 `settings.defaultTools`。
|
|
98
|
+
|
|
99
|
+
- `-t`, `--tools <清单>`:**严格白名单** —— 最终工具集就是这些(不再叠加默认集)。
|
|
100
|
+
- `-xt`, `--exclude-tools <清单>`:从最终集合里排除(在其它选择之后生效)。
|
|
101
|
+
- `-nbt`, `--no-builtin-tools`:禁用内置工具,**保留扩展装的**。
|
|
102
|
+
- `-nt`, `--no-tools`:禁用全部工具。
|
|
103
|
+
|
|
104
|
+
`-t` 与 `-nt` / `-nbt` **互斥**(同给报错退出码 2);未注册的名字会报一条提示,不静默丢。
|
|
105
|
+
|
|
106
|
+
## 资源
|
|
107
|
+
|
|
108
|
+
- `--skill <路径>`:额外加载一个技能(可重复,叠加;优先级高于所有自动发现层)。
|
|
109
|
+
- `--no-skills` / `-ns`:关掉技能的**自动发现**(`--skill` 仍生效)。
|
|
110
|
+
- `-e`, `--extension <目录>`:本次运行临时加载一个扩展目录(仅本进程,`scope=temporary`;TUI 与无头都生效)。
|
|
111
|
+
- `--ext name=value`:给扩展声明过的**旗标**传值(与 `-e` 不是一回事)。见 [extensions.md](extensions.md)。
|
|
112
|
+
- `--no-extensions` / `-ne`:关掉扩展**发现**,`-e` 显式给的仍生效。
|
|
113
|
+
|
|
114
|
+
## 提示词与上下文
|
|
115
|
+
|
|
116
|
+
- `--system-prompt "<文本\|文件>"`:**整体替换**基座(与 `.qi/SYSTEM.md` 同语义,只本次运行)。
|
|
117
|
+
- `--append-system-prompt "<文本\|文件>"`:追加到每回合提示词末尾(可重复,空行连接)。
|
|
118
|
+
- `--no-context-files` / `-nc`:不注入 `AGENTS.md` / `CLAUDE.md`。
|
|
119
|
+
|
|
120
|
+
语义与副作用见 [configuration.md](configuration.md)。
|
|
121
|
+
|
|
122
|
+
## 界面
|
|
123
|
+
|
|
124
|
+
- `--tui-mode <regular|fullscreen>`:渲染模式(非法值退出码 2),压过 `settings.tuiMode`。
|
|
125
|
+
`fullscreen`(默认)= 备用屏、qi 拥有视口;`regular` = inline、滚动交给终端。见 [usage.md](usage.md)。
|
|
126
|
+
|
|
127
|
+
## 包命令
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
qi install <来源> [-l] # 调 pip + 写进 settings.packages;本地目录只登记(不调 pip)
|
|
131
|
+
qi remove <来源> [-l] # 只从声明里移除(不卸包),并把 pip uninstall 命令打出来
|
|
132
|
+
qi uninstall <来源> [-l] # 同 remove
|
|
133
|
+
qi update [来源|self] [--self|--extensions|--all|--extension <来源>|--force]
|
|
134
|
+
qi list # 列出已装扩展,并与声明双向比对
|
|
135
|
+
qi doctor # 诊断(含「声明了没装」的可复制装法)
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`qi update` 默认只更 qi 自己;`--models` 在 qi **无意义**(模型全在 `models.json` 里自己维护,它只说明这一点然后退出)。
|
|
139
|
+
`qi install` 每次都先把要跑的 pip 命令打出来 —— 目标环境(自家 venv / `uv tool` / 只读解释器)的行为真的不同,
|
|
140
|
+
声明与比对的意义见 [packages.md](packages.md)。
|
|
141
|
+
|
|
142
|
+
## 设置与初始化
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
qi config [-l] [--get K] [--set K=V] [--unset K] [--json]
|
|
146
|
+
qi init [-y] [-l] [--provider … --model …]
|
|
147
|
+
qi init --list-presets | --preset <名>[,<名>] | --refresh <名>[,<名>] | --refresh-all
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
- `qi config` 不带旗标:TTY 下开**资源启停面板**(space 勾选 · `ctrl+s` 保存 · `escape` 取消;`-l` 切作用域),
|
|
151
|
+
非 TTY 打一张资源表 + 设置总览。`--set` 的值先按 JSON 解析、失败则当字符串;`--get` / `--unset` 支持点号路径。
|
|
152
|
+
字段清单见 [settings.md](settings.md)。
|
|
153
|
+
- 资源的"关"写的是**声明**:目录扩展 → `settings.extensions[]` 加 `-<路径>`;pip 包 → `packages` 里那条换成
|
|
154
|
+
`{"source": …, "extensions": []}`。所以状态被记住,也能表达"项目关掉、全局还开着"。
|
|
155
|
+
- `qi init` 引导默认模型,详见 [models.md](models.md)。
|
|
156
|
+
|
|
157
|
+
## 凭证命令
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
qi auth login <provider> # 输入可见地写入 auth.json(0600);TUI 里的 /login 是遮罩输入
|
|
161
|
+
qi auth logout <provider> # 删除该 provider 的凭证(= qi auth rm)
|
|
162
|
+
qi auth list # 只列有凭证的 provider 名
|
|
163
|
+
qi auth print-api-key [--provider P] [--model M]
|
|
164
|
+
qi auth print-bearer-token [--provider P] [--model M] [--min-expiry 30m]
|
|
165
|
+
qi auth check [--provider P] [--model M] [--json] [--credentials]
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- 解析顺序:auth store(`auth.json`)→ 约定环境变量 → `models.json` 的 `apiKey` 引用。
|
|
169
|
+
- 只读子命令必须给 `--provider` 或 `--model` 之一(只给 `--model` 时反查 provider;多家都有该模型时报错)。
|
|
170
|
+
- `check` 退出码:`ready`=0 / `not_ready`=1 / `invalid`=2。`print-bearer-token` 的 `--min-expiry` **只校验格式**
|
|
171
|
+
(`qi` 的凭证没有过期时间)。两个命令都接受 `--no-refresh` 但它是空操作 —— **qi 没有 OAuth**。
|
|
172
|
+
|
|
173
|
+
## 信任
|
|
174
|
+
|
|
175
|
+
- `-a`, `--approve`:信任本项目 `.qi`(本次运行,加载项目级扩展)。
|
|
176
|
+
- `-na`, `--no-approve`:明确不信任。两个同给报错退出码 2。
|
|
177
|
+
|
|
178
|
+
未表态时看用户级 `settings.defaultProjectTrust`(`ask` 在无头下保守判不信任,并在 stderr 提示用 `-a`)。
|
|
179
|
+
见 [security.md](security.md)。
|
|
180
|
+
|
|
181
|
+
## 通用
|
|
182
|
+
|
|
183
|
+
- `-h`, `--help` / `-v`, `--version` / `--`(结束参数解析)。
|
|
184
|
+
- `--verbose`:把工具进度打到 stderr(TUI 里本来就有)。
|
|
185
|
+
- `--offline`:**被接受但什么都不做** —— qi 启动期没有任何网络操作(唯一联网的地方是模型调用)。
|
|
186
|
+
- **短旗标 `-t` / `-xt` / `-nt` / `-nbt` 是 argv 展开**:click 会按字符解析短选项(`-nt` 会被拆成 `-n t`,
|
|
187
|
+
会话名静默变成 `"t"`),所以 qi 在交给 typer 之前把它们展开成长写法。代价是值位置上的 `-nt` 不被当旗标
|
|
188
|
+
(`qi -n -nt` = 会话名叫 `-nt`)—— 这是对的。
|