@zhushanwen/pi-permission 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +455 -0
  2. package/index.ts +2 -0
  3. package/package.json +52 -0
  4. package/src/__tests__/approval.test.ts +456 -0
  5. package/src/__tests__/commands.test.ts +487 -0
  6. package/src/__tests__/config.test.ts +229 -0
  7. package/src/__tests__/e2e-modes.test.ts +253 -0
  8. package/src/__tests__/extension.test.ts +170 -0
  9. package/src/__tests__/index-integration.test.ts +282 -0
  10. package/src/__tests__/model-picker.test.ts +371 -0
  11. package/src/__tests__/pipeline.test.ts +976 -0
  12. package/src/__tests__/production.test.ts +102 -0
  13. package/src/__tests__/rule-editor-component.test.ts +475 -0
  14. package/src/__tests__/rule-editor.test.ts +252 -0
  15. package/src/__tests__/rule-templates.test.ts +264 -0
  16. package/src/__tests__/statusline.test.ts +221 -0
  17. package/src/__tests__/types.test.ts +59 -0
  18. package/src/approval.ts +352 -0
  19. package/src/ast/__tests__/analyzer.test.ts +262 -0
  20. package/src/ast/__tests__/loader.test.ts +136 -0
  21. package/src/ast/analyzer.ts +326 -0
  22. package/src/ast/index.ts +7 -0
  23. package/src/ast/loader.ts +113 -0
  24. package/src/classifier/__tests__/classifier.test.ts +285 -0
  25. package/src/classifier/__tests__/json-parser.test.ts +118 -0
  26. package/src/classifier/__tests__/model-resolver.test.ts +173 -0
  27. package/src/classifier/__tests__/prompt.test.ts +59 -0
  28. package/src/classifier/classifier.ts +242 -0
  29. package/src/classifier/index.ts +28 -0
  30. package/src/classifier/json-parser.ts +127 -0
  31. package/src/classifier/model-resolver.ts +295 -0
  32. package/src/classifier/prompt.ts +52 -0
  33. package/src/commands.ts +234 -0
  34. package/src/config.ts +216 -0
  35. package/src/index.ts +291 -0
  36. package/src/model-picker.ts +398 -0
  37. package/src/pipeline.ts +541 -0
  38. package/src/production.ts +102 -0
  39. package/src/rule-editor-component.ts +883 -0
  40. package/src/rule-editor.ts +262 -0
  41. package/src/rule-templates.ts +332 -0
  42. package/src/rules/__tests__/builtins.test.ts +614 -0
  43. package/src/rules/__tests__/index.test.ts +26 -0
  44. package/src/rules/__tests__/matcher.test.ts +378 -0
  45. package/src/rules/__tests__/wildcard.test.ts +84 -0
  46. package/src/rules/builtins.ts +680 -0
  47. package/src/rules/index.ts +27 -0
  48. package/src/rules/matcher.ts +160 -0
  49. package/src/rules/wildcard.ts +43 -0
  50. package/src/statusline.ts +226 -0
  51. package/src/types.ts +249 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * AI Classifier 主流程(I4 classifyRisk)
3
+ *
4
+ * 流程:resolveModel → 构造 Model<Api> + Context + Options → streamSimple →
5
+ * 检查 stopReason → 解析文本 → ClassifierResult。所有失败路径返回 fail-closed ask。
6
+ *
7
+ * 关键设计:
8
+ * - 依赖注入 ClassifierDeps(streamSimple 注入,便于测试 mock,不直接 import)
9
+ * - 超时/中止:传 provider 原生 timeoutMs + signal 给 streamSimple;外层再叠
10
+ * Promise.race 兜底(防止 provider 不支持 timeoutMs 时 result() 永挂)
11
+ * - G3 修正:EventStream.result() 只 resolve 不 reject,error/aborted 也 resolve
12
+ * (带 stopReason)。因此 classifyRisk 在取 text 前显式检查 stopReason,
13
+ * error/aborted → fallback ask
14
+ * - fail-closed:timeout / 抛错 / 解析失败 / 无可用模型 → 一律 ask
15
+ */
16
+
17
+ import type {
18
+ Api,
19
+ AssistantMessageEventStream,
20
+ Context,
21
+ Model,
22
+ SimpleStreamOptions,
23
+ } from "@earendil-works/pi-ai";
24
+
25
+ import type {
26
+ ClassifierConfig,
27
+ ClassifierResult,
28
+ ToolInvocationContext,
29
+ } from "../types.js";
30
+ import { parseClassifierResponse } from "./json-parser.js";
31
+ import { type ResolvedModel } from "./model-resolver.js";
32
+ import { buildClassifierUserPrompt, CLASSIFIER_SYSTEM_PROMPT } from "./prompt.js";
33
+
34
+ // ──────────────────────── fail-closed fallback ────────────────────────
35
+
36
+ /** 秒→毫秒换算系数(用于 timeout 字段转 provider 原生 timeoutMs) */
37
+ const MILLIS_PER_SECOND = 1000;
38
+
39
+ /** classifyRisk 所有失败路径的统一 fallback(fail-closed ask) */
40
+ const CLASSIFY_FALLBACK_RESULT: ClassifierResult = {
41
+ outcome: "ask",
42
+ risk_level: "medium",
43
+ reasoning: "classifier unavailable",
44
+ confidence: 0,
45
+ };
46
+
47
+ // ──────────────────────── 依赖注入 ────────────────────────
48
+
49
+ /**
50
+ * Classifier 的外部依赖(DI 便于测试 mock)。
51
+ *
52
+ * - resolveModel:把 ClassifierConfig.model 解析为 ResolvedModel(或 null)
53
+ * - streamSimple:pi-ai 的流式调用(同步返回 EventStream,result() 异步)
54
+ * - onLog:可选日志回调(调试/审计)
55
+ *
56
+ * 命名锁定为 streamSimple(G7:不是 callStreamSimple)。
57
+ */
58
+ export interface ClassifierDeps {
59
+ resolveModel: (config: ClassifierConfig) => ResolvedModel | null;
60
+ streamSimple: (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => AssistantMessageEventStream;
61
+ onLog?: (msg: string) => void;
62
+ }
63
+
64
+ // ──────────────────────── AssistantMessage 最小子集 ────────────────────────
65
+
66
+ /** AssistantMessage 的最小子集(只取 stopReason + content,避免泛型/依赖膨胀) */
67
+ interface AssistantMessageLike {
68
+ stopReason?: string;
69
+ content?: { type: string; text?: string }[];
70
+ }
71
+
72
+ // ──────────────────────── 辅助:构造 Model<Api> ────────────────────────
73
+
74
+ /**
75
+ * 从 ResolvedModel 构造 pi-ai 的 Model<Api>。
76
+ *
77
+ * ResolvedModel.api 是字符串(来自 models.json),断言为 Api 联合类型。
78
+ * cost 字段携带 input 真实值(G4),其余填 0(classifier 不关心 token 成本计量)。
79
+ */
80
+ function buildModel(resolved: ResolvedModel): Model<Api> {
81
+ const inputCost = typeof resolved.inputCost === "number" ? resolved.inputCost : 0;
82
+ return {
83
+ id: resolved.id,
84
+ name: resolved.name ?? resolved.id,
85
+ api: resolved.api as Api,
86
+ provider: resolved.provider,
87
+ // G4:baseUrl 用真实值(无则空串,provider 内部会补默认)
88
+ baseUrl: resolved.baseUrl ?? "",
89
+ reasoning: false,
90
+ input: ["text"],
91
+ cost: { input: inputCost, output: 0, cacheRead: 0, cacheWrite: 0 },
92
+ contextWindow: 128000,
93
+ maxTokens: 16384,
94
+ };
95
+ }
96
+
97
+ /** 构造无 transcript 的 Context(单轮 user message) */
98
+ function buildContext(ctx: ToolInvocationContext): Context {
99
+ return {
100
+ systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
101
+ messages: [
102
+ {
103
+ role: "user",
104
+ content: [{ type: "text", text: buildClassifierUserPrompt(ctx) }],
105
+ timestamp: Date.now(),
106
+ },
107
+ ],
108
+ };
109
+ }
110
+
111
+ /** 从 AssistantMessage.content 抽取纯文本(拼接所有 text 块) */
112
+ function extractAssistantText(content: { type: string; text?: string }[]): string {
113
+ return content
114
+ .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
115
+ .map((c) => c.text)
116
+ .join("");
117
+ }
118
+
119
+ // ──────────────────────── 外层超时(兜底) ────────────────────────
120
+
121
+ /**
122
+ * 给 result() 叠加外层超时(毫秒)+ abort 信号。
123
+ *
124
+ * 防御 provider 不支持 timeoutMs 时 result() 永挂。result() 自身只 resolve 不 reject,
125
+ * 故 race 不会因 provider 错误提前 reject——错误走 stopReason 路径。
126
+ */
127
+ function raceResultWithDeadline(
128
+ resultPromise: Promise<AssistantMessageLike>,
129
+ timeoutMs: number | undefined,
130
+ signal: AbortSignal | undefined,
131
+ ): Promise<{ kind: "ok"; message: AssistantMessageLike } | { kind: "timeout" } | { kind: "aborted" }> {
132
+ type Outcome = { kind: "ok"; message: AssistantMessageLike } | { kind: "timeout" } | { kind: "aborted" };
133
+ const racers: Promise<Outcome>[] = [
134
+ resultPromise.then((message) => ({ kind: "ok" as const, message })),
135
+ ];
136
+
137
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
138
+ if (timeoutMs !== undefined && timeoutMs > 0) {
139
+ racers.push(
140
+ new Promise<Outcome>((resolve) => {
141
+ timeoutHandle = setTimeout(() => resolve({ kind: "timeout" }), timeoutMs);
142
+ }),
143
+ );
144
+ }
145
+ if (signal !== undefined) {
146
+ if (signal.aborted) {
147
+ return Promise.resolve({ kind: "aborted" });
148
+ }
149
+ racers.push(
150
+ new Promise<Outcome>((resolve) => {
151
+ signal.addEventListener("abort", () => resolve({ kind: "aborted" }), { once: true });
152
+ }),
153
+ );
154
+ }
155
+
156
+ return Promise.race(racers).finally(() => {
157
+ if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
158
+ });
159
+ }
160
+
161
+ // ──────────────────────── createClassifier ────────────────────────
162
+
163
+ /**
164
+ * 创建 classifier 实例(工厂,注入 deps)。
165
+ *
166
+ * 返回 classifyRisk 主函数。
167
+ */
168
+ export function createClassifier(deps: ClassifierDeps): {
169
+ classifyRisk: (ctx: ToolInvocationContext, config: ClassifierConfig, signal?: AbortSignal) => Promise<ClassifierResult>;
170
+ } {
171
+ const { resolveModel, streamSimple, onLog } = deps;
172
+
173
+ async function classifyRisk(
174
+ ctx: ToolInvocationContext,
175
+ config: ClassifierConfig,
176
+ signal?: AbortSignal,
177
+ ): Promise<ClassifierResult> {
178
+ // 1. 解析模型;无可用模型 → fail-closed
179
+ const resolved = resolveModel(config);
180
+ if (resolved === null) {
181
+ onLog?.("[pi-permission] classifier: no model resolved, returning fallback");
182
+ return { ...CLASSIFY_FALLBACK_RESULT };
183
+ }
184
+
185
+ // 2. 构造 model/context/options(timeout 秒→毫秒,传 provider 原生 timeoutMs + signal + apiKey)
186
+ const model = buildModel(resolved);
187
+ const context = buildContext(ctx);
188
+ const timeoutMs = config.timeout > 0 ? config.timeout * MILLIS_PER_SECOND : undefined;
189
+ const options: SimpleStreamOptions = {
190
+ ...(timeoutMs !== undefined ? { timeoutMs } : {}),
191
+ ...(signal !== undefined ? { signal } : {}),
192
+ ...(resolved.apiKey !== undefined ? { apiKey: resolved.apiKey } : {}),
193
+ };
194
+
195
+ // 3. 调用 streamSimple(同步返回 EventStream)。包裹 try/catch 防同步抛错。
196
+ let stream: AssistantMessageEventStream;
197
+ try {
198
+ stream = streamSimple(model, context, options);
199
+ } catch (error) {
200
+ const message = error instanceof Error ? error.message : String(error);
201
+ onLog?.(`[pi-permission] classifier: streamSimple threw: ${message}`);
202
+ return { ...CLASSIFY_FALLBACK_RESULT };
203
+ }
204
+
205
+ // 4. 等待 result() + 外层超时/中止兜底。
206
+ // G3:result() 只 resolve 不 reject,error/aborted 也 resolve(带 stopReason)。
207
+ const resultPromise = stream.result() as Promise<AssistantMessageLike>;
208
+ let settled: { kind: "ok"; message: AssistantMessageLike } | { kind: "timeout" } | { kind: "aborted" };
209
+ try {
210
+ settled = await raceResultWithDeadline(resultPromise, timeoutMs, signal);
211
+ } catch (error) {
212
+ // result() 理论上不 reject,但防御性 catch(fail-closed)
213
+ const message = error instanceof Error ? error.message : String(error);
214
+ onLog?.(`[pi-permission] classifier: result race threw: ${message}`);
215
+ return { ...CLASSIFY_FALLBACK_RESULT };
216
+ }
217
+
218
+ if (settled.kind === "timeout") {
219
+ onLog?.(`[pi-permission] classifier: timed out after ${timeoutMs}ms`);
220
+ return { ...CLASSIFY_FALLBACK_RESULT };
221
+ }
222
+ if (settled.kind === "aborted") {
223
+ onLog?.(`[pi-permission] classifier: aborted by signal`);
224
+ return { ...CLASSIFY_FALLBACK_RESULT };
225
+ }
226
+
227
+ // 5. G3 关键修正:显式检查 stopReason。error/aborted → fallback(不能当成功)
228
+ const message = settled.message;
229
+ if (message?.stopReason === "error" || message?.stopReason === "aborted") {
230
+ const errorDetail = message?.content?.[0]?.text ?? "unknown error";
231
+ onLog?.(`[pi-permission] classifier: stream stopReason=${message.stopReason}, error=${errorDetail}, returning fallback`);
232
+ return { ...CLASSIFY_FALLBACK_RESULT };
233
+ }
234
+
235
+ // 6. 提取文本 → 三层容错解析
236
+ const content = Array.isArray(message?.content) ? (message.content as { type: string; text?: string }[]) : [];
237
+ const text = extractAssistantText(content);
238
+ return parseClassifierResponse(text);
239
+ }
240
+
241
+ return { classifyRisk };
242
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * AI Classifier barrel(层 3 入口)
3
+ *
4
+ * 对外暴露 createClassifier + ClassifierDeps + prompt/json-parser/model-resolver 辅助 API。
5
+ * ClassifierResult / ClassifierConfig / ToolInvocationContext 从 ../types.js re-export,
6
+ * 单一类型来源(types.ts)。
7
+ */
8
+
9
+ export type { ClassifierDeps } from "./classifier.js";
10
+ export { createClassifier } from "./classifier.js";
11
+ export { PARSE_FALLBACK_RESULT,parseClassifierResponse } from "./json-parser.js";
12
+ export type { ResolvedModel, ResolvedModelEntry } from "./model-resolver.js";
13
+ export {
14
+ agentDir,
15
+ findCheapestModel,
16
+ flattenModels,
17
+ loadModelsJson,
18
+ resolveClassifierModel,
19
+ } from "./model-resolver.js";
20
+ export { buildClassifierUserPrompt,CLASSIFIER_SYSTEM_PROMPT } from "./prompt.js";
21
+
22
+ // 类型 re-export(单一来源:types.ts)
23
+ export type {
24
+ ClassifierConfig,
25
+ ClassifierResult,
26
+ RiskLevel,
27
+ ToolInvocationContext,
28
+ } from "../types.js";
@@ -0,0 +1,127 @@
1
+ /**
2
+ * AI Classifier 输出解析(三层容错 → ClassifierResult)
3
+ *
4
+ * 对应 I4 classifyRisk 的「LLM 响应 → 结构化结果」环节。
5
+ *
6
+ * 三层容错:
7
+ * 1. 正则提取首个 JSON 对象(贪婪匹配)
8
+ * 2. JSON.parse(try/catch)
9
+ * 3. 字段验证(枚举 + 数值)
10
+ *
11
+ * 任意一层失败 → 返回 fail-closed fallback(outcome=ask, risk_level=medium, confidence=0)。
12
+ */
13
+
14
+ import type { ClassifierResult, PermissionAction, RiskLevel } from "../types.js";
15
+
16
+ /** 解析失败时的 fail-closed fallback(统一 medium/ask,绝不静默放行) */
17
+ export const PARSE_FALLBACK_RESULT: ClassifierResult = {
18
+ outcome: "ask",
19
+ risk_level: "medium",
20
+ reasoning: "classifier output parse failed",
21
+ confidence: 0,
22
+ };
23
+
24
+ const VALID_ACTIONS: readonly PermissionAction[] = ["allow", "deny", "ask"];
25
+ const VALID_RISKS: readonly RiskLevel[] = ["low", "medium", "high"];
26
+
27
+ /**
28
+ * 提取首个 JSON 对象子串。
29
+ *
30
+ * 用贪婪正则 `/\{[\s\S]*\}/` 取最后一个 `}` 为止的子串。
31
+ *
32
+ * 已知 limitation(G5):当 LLM 输出多个独立 JSON 对象(如 `{"a":1} {"b":2}`)时,
33
+ * 贪婪匹配会吞掉中间空格得到 `{...}{...}`,JSON.parse 失败 → fallback。
34
+ * 实际分类器场景下 LLM 只应返回单个对象,此权衡换取「单对象 + 尾随文本」的鲁棒性。
35
+ */
36
+ function extractJsonObject(text: string): string | null {
37
+ const match = text.match(/\{[\s\S]*\}/);
38
+ return match ? match[0] : null;
39
+ }
40
+
41
+ function isStringRecord(value: unknown): value is Record<string, unknown> {
42
+ return typeof value === "object" && value !== null && !Array.isArray(value);
43
+ }
44
+
45
+ /** 验证并提取 outcome(容忍大小写) */
46
+ function readAction(raw: unknown): PermissionAction | null {
47
+ if (typeof raw !== "string") return null;
48
+ const lower = raw.toLowerCase();
49
+ return VALID_ACTIONS.includes(lower as PermissionAction) ? (lower as PermissionAction) : null;
50
+ }
51
+
52
+ /** 验证并提取 risk_level(容忍大小写) */
53
+ function readRiskLevel(raw: unknown): RiskLevel | null {
54
+ if (typeof raw !== "string") return null;
55
+ const lower = raw.toLowerCase();
56
+ return VALID_RISKS.includes(lower as RiskLevel) ? (lower as RiskLevel) : null;
57
+ }
58
+
59
+ /**
60
+ * 验证并提取 confidence:必须是有限数字,clamp 到 [0,1]。
61
+ *
62
+ * 接受字符串数字(LLM 偶发返回 `"0.8"`),拒绝 NaN/Infinity/非数字。
63
+ */
64
+ function readConfidence(raw: unknown): number | null {
65
+ let num: number;
66
+ if (typeof raw === "number") {
67
+ num = raw;
68
+ } else if (typeof raw === "string" && raw.trim().length > 0) {
69
+ num = Number(raw);
70
+ } else {
71
+ return null;
72
+ }
73
+ if (!Number.isFinite(num)) return null;
74
+ if (num < 0) num = 0;
75
+ if (num > 1) num = 1;
76
+ return num;
77
+ }
78
+
79
+ /** 兼容 LLM 偶发把 reasoning 写成 reason(取先存在者) */
80
+ function readReasoning(raw: unknown): string {
81
+ if (typeof raw === "string" && raw.length > 0) return raw;
82
+ return PARSE_FALLBACK_RESULT.reasoning;
83
+ }
84
+
85
+ /**
86
+ * 解析 classifier LLM 输出文本 → ClassifierResult。
87
+ *
88
+ * @param text LLM 返回的原始文本(可能含 markdown code fence / 前后 prose)
89
+ * @returns 始终返回 ClassifierResult;解析失败返回 PARSE_FALLBACK_RESULT(fail-closed ask)
90
+ */
91
+ export function parseClassifierResponse(text: string): ClassifierResult {
92
+ if (typeof text !== "string" || text.length === 0) {
93
+ return { ...PARSE_FALLBACK_RESULT };
94
+ }
95
+
96
+ const jsonStr = extractJsonObject(text);
97
+ if (jsonStr === null) {
98
+ return { ...PARSE_FALLBACK_RESULT };
99
+ }
100
+
101
+ let parsed: unknown;
102
+ try {
103
+ parsed = JSON.parse(jsonStr);
104
+ } catch {
105
+ return { ...PARSE_FALLBACK_RESULT };
106
+ }
107
+
108
+ if (!isStringRecord(parsed)) {
109
+ return { ...PARSE_FALLBACK_RESULT };
110
+ }
111
+
112
+ const outcome = readAction(parsed.outcome);
113
+ const risk_level = readRiskLevel(parsed.risk_level);
114
+ const confidence = readConfidence(parsed.confidence ?? parsed.score);
115
+
116
+ // 三个核心字段必须全部有效;缺任一即 fail-closed
117
+ if (outcome === null || risk_level === null || confidence === null) {
118
+ return { ...PARSE_FALLBACK_RESULT };
119
+ }
120
+
121
+ return {
122
+ outcome,
123
+ risk_level,
124
+ reasoning: readReasoning(parsed.reasoning ?? parsed.reason),
125
+ confidence,
126
+ };
127
+ }
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Classifier 模型解析(model='auto' → 最便宜可用模型)
3
+ *
4
+ * 移植自 pi-coding-agent 的 ModelRegistry 思路,但只读 models.json(不刷新 OAuth、
5
+ * 不合并内置模型),保持轻量。核心:
6
+ * - agentDir():解析 agent 根目录(与 config.ts 同逻辑,G2 自实现,不依赖未导出的 getAgentDir)
7
+ * - loadModelsJson():读 <agentDir>/models.json,含 onWarning 回调(G6)
8
+ * - findCheapestModel():拍平 providers → 过滤 hasApiKey → 按 input cost 升序
9
+ * - resolveClassifierModel():'auto' / 'provider/model-id' 两路解析
10
+ *
11
+ * 不直接 import pi-ai 的 Model 类型用于返回(ResolvedModel 只携带构造 Model<Api> 必需的字段,
12
+ * 避免 here-stringing 一个完整 Model<Api>——api 是字符串联合类型,跨 provider 边界难捏造)。
13
+ */
14
+
15
+ import { existsSync, readFileSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ // ──────────────────────── agentDir(G2 自实现) ────────────────────────
20
+
21
+ /**
22
+ * 解析 agent 根目录。
23
+ *
24
+ * 与 config.ts 的 getAgentDir 逻辑一致(env override 优先,否则 ~/.pi/agent),
25
+ * 但此处显式 export 供 classifier 子模块复用——config.ts 的 getAgentDir 未导出。
26
+ */
27
+ export function agentDir(): string {
28
+ const override = process.env.PI_CODING_AGENT_DIR?.trim();
29
+ if (override) return override;
30
+ return join(homedir(), ".pi", "agent");
31
+ }
32
+
33
+ // ──────────────────────── models.json schema(最小子集) ────────────────────────
34
+
35
+ /** 单个 model 的 cost 结构(与 pi-ai Model.cost 同形) */
36
+ interface ModelCost {
37
+ input: number;
38
+ output: number;
39
+ cacheRead: number;
40
+ cacheWrite: number;
41
+ }
42
+
43
+ /** models.json 中单个 model 定义的最小子集(参考 pi-coding-agent parseModels) */
44
+ interface ModelsJsonModelDef {
45
+ id: string;
46
+ name?: string;
47
+ api?: string;
48
+ baseUrl?: string;
49
+ reasoning?: boolean;
50
+ input?: ("text" | "image")[];
51
+ cost?: ModelCost;
52
+ contextWindow?: number;
53
+ maxTokens?: number;
54
+ }
55
+
56
+ /** models.json 中 provider 定义的最小子集 */
57
+ interface ModelsJsonProviderDef {
58
+ name?: string;
59
+ baseUrl?: string;
60
+ apiKey?: string;
61
+ api?: string;
62
+ models?: ModelsJsonModelDef[];
63
+ }
64
+
65
+ /** models.json 顶层结构 */
66
+ interface ModelsJsonFile {
67
+ providers?: Record<string, ModelsJsonProviderDef>;
68
+ }
69
+
70
+ /**
71
+ * 解析后的「扁平 model 条目」:附带其 provider 名 + 是否有 apiKey(auth 可用)。
72
+ *
73
+ * 这是 findCheapestModel / resolveClassifierModel 的中间表示,避免直接构造
74
+ * pi-ai Model<Api>(api 联合类型难以跨 provider 安全构造)。
75
+ */
76
+ export interface ResolvedModelEntry {
77
+ provider: string;
78
+ id: string;
79
+ name: string;
80
+ api: string;
81
+ baseUrl?: string;
82
+ cost: ModelCost;
83
+ /** 该 provider 在 models.json 是否配置了 apiKey(auth 可用) */
84
+ hasApiKey: boolean;
85
+ /** 该 provider 的 apiKey(用于 streamSimple 调用) */
86
+ apiKey?: string;
87
+ }
88
+
89
+ // ──────────────────────── loadModelsJson ────────────────────────
90
+
91
+ /**
92
+ * 读取并解析 models.json。
93
+ *
94
+ * @param onWarning 非致命问题(文件缺失/解析失败)的警告回调;不抛错(fail-closed:返回 null)
95
+ * @returns 解析后的 ModelsJsonFile;文件不存在或解析失败返回 null
96
+ *
97
+ * G6:显式 onWarning 参数,让调用方决定如何上报(console.warn / telemetry / 静默)。
98
+ */
99
+ export function loadModelsJson(
100
+ onWarning?: (msg: string) => void,
101
+ filePath?: string,
102
+ ): ModelsJsonFile | null {
103
+ const path = filePath ?? join(agentDir(), "models.json");
104
+ if (!existsSync(path)) {
105
+ return null;
106
+ }
107
+ try {
108
+ const raw = readFileSync(path, "utf-8");
109
+ const parsed: unknown = JSON.parse(raw);
110
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
111
+ onWarning?.(`[pi-permission] models.json root is not an object: ${path}`);
112
+ return null;
113
+ }
114
+ return parsed as ModelsJsonFile;
115
+ } catch (error) {
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ onWarning?.(`[pi-permission] Failed to parse models.json at '${path}': ${message}`);
118
+ return null;
119
+ }
120
+ }
121
+
122
+ // ──────────────────────── 拍平 + 查找 ────────────────────────
123
+
124
+ const DEFAULT_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
125
+
126
+ /** 把 models.json 拍平成 ResolvedModelEntry 列表(每个 provider.model 一条) */
127
+ export function flattenModels(data: ModelsJsonFile): ResolvedModelEntry[] {
128
+ const providers = data.providers ?? {};
129
+ const out: ResolvedModelEntry[] = [];
130
+ for (const [providerName, providerDef] of Object.entries(providers)) {
131
+ if (!providerDef || typeof providerDef !== "object") continue;
132
+ const hasApiKey = typeof providerDef.apiKey === "string" && providerDef.apiKey.length > 0;
133
+ const apiKey = hasApiKey ? providerDef.apiKey : undefined;
134
+ const modelDefs = Array.isArray(providerDef.models) ? providerDef.models : [];
135
+ for (const m of modelDefs) {
136
+ if (!m || typeof m.id !== "string") continue;
137
+ const api = m.api ?? providerDef.api;
138
+ if (typeof api !== "string" || api.length === 0) continue; // 无法构造 Model<Api>
139
+ out.push({
140
+ provider: providerName,
141
+ id: m.id,
142
+ name: typeof m.name === "string" ? m.name : m.id,
143
+ api,
144
+ baseUrl: typeof m.baseUrl === "string" ? m.baseUrl : typeof providerDef.baseUrl === "string" ? providerDef.baseUrl : undefined,
145
+ cost: m.cost ?? DEFAULT_COST,
146
+ hasApiKey,
147
+ apiKey,
148
+ });
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ /**
155
+ * 在可用模型(hasApiKey===true)中找 input cost 最低的。
156
+ *
157
+ * @returns 最便宜的 ResolvedModelEntry;无可用模型返回 null
158
+ */
159
+ export function findCheapestModel(data: ModelsJsonFile): ResolvedModelEntry | null {
160
+ const entries = flattenModels(data).filter((m) => m.hasApiKey);
161
+ if (entries.length === 0) return null;
162
+ // 升序按 input cost,并列时取首个稳定项
163
+ entries.sort((a, b) => a.cost.input - b.cost.input);
164
+ return entries[0];
165
+ }
166
+
167
+ // ──────────────────────── listAvailableModels(W7 model picker 用) ────────────────────────
168
+
169
+ /**
170
+ * 列出所有「可用」(provider 配了 apiKey)的模型,按 provider 分组成 Map。
171
+ *
172
+ * model picker(/permission model)用:第一级选 provider,第二级选该 provider 下的 model。
173
+ * 与 findCheapestModel 的区别:
174
+ * - findCheapestModel 只返回单个最便宜模型('auto' 解析用)
175
+ * - listAvailableModels 返回全量分组(picker 展示用)
176
+ *
177
+ * 排序规则:
178
+ * - provider 内 model 按 cost.input 升序,并列时按 id 字母序 tiebreaker
179
+ * - provider 之间按字母序(Map 保持插入序,便于 picker 稳定展示)
180
+ *
181
+ * 文件缺失 / 解析失败 → 返回空 Map(不 throw,调用方据此降级为「无可选模型」提示)。
182
+ *
183
+ * @param onWarning 文件读取/解析问题的警告回调(透传给 loadModelsJson)
184
+ * @param filePath 可选,自定义 models.json 路径(测试用)
185
+ * @returns Map<providerName, ResolvedModelEntry[]>(仅含 hasApiKey=true 的 provider)
186
+ */
187
+ export function listAvailableModels(
188
+ onWarning?: (msg: string) => void,
189
+ filePath?: string,
190
+ ): Map<string, ResolvedModelEntry[]> {
191
+ const file = loadModelsJson(onWarning, filePath);
192
+ if (file === null) return new Map();
193
+
194
+ const entries = flattenModels(file).filter((m) => m.hasApiKey);
195
+ // 按 provider 分组
196
+ const grouped = new Map<string, ResolvedModelEntry[]>();
197
+ for (const entry of entries) {
198
+ const list = grouped.get(entry.provider);
199
+ if (list === undefined) {
200
+ grouped.set(entry.provider, [entry]);
201
+ } else {
202
+ list.push(entry);
203
+ }
204
+ }
205
+
206
+ // provider 内排序:cost.input 升序 + id 字母序 tiebreaker
207
+ for (const list of grouped.values()) {
208
+ list.sort((a, b) => {
209
+ if (a.cost.input !== b.cost.input) return a.cost.input - b.cost.input;
210
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
211
+ });
212
+ }
213
+
214
+ // provider 间按字母序重排(新建 Map 保持插入序)
215
+ if (grouped.size > 1) {
216
+ const sortedProviders = [...grouped.keys()].sort();
217
+ const reordered = new Map<string, ResolvedModelEntry[]>();
218
+ for (const p of sortedProviders) {
219
+ reordered.set(p, grouped.get(p)!);
220
+ }
221
+ return reordered;
222
+ }
223
+ return grouped;
224
+ }
225
+
226
+ // ──────────────────────── resolveClassifierModel ────────────────────────
227
+
228
+ /**
229
+ * classifier 解析后的模型规格(用于在 classifier.ts 构造 Model<Api> 调用 streamSimple)。
230
+ *
231
+ * G4:携带 baseUrl?/name?/inputCost?,让构造 Model<Api> 时能填真实值。
232
+ */
233
+ export interface ResolvedModel {
234
+ provider: string;
235
+ id: string;
236
+ api: string;
237
+ name?: string;
238
+ baseUrl?: string;
239
+ inputCost?: number;
240
+ apiKey?: string;
241
+ }
242
+
243
+ /**
244
+ * 把 ClassifierConfig.model 规格解析为具体模型。
245
+ *
246
+ * - 'auto':findCheapestModel(最便宜 + hasApiKey)
247
+ * - 'provider/model-id':精确查找(hasApiKey 不强制,调用方负责报错)
248
+ * - 其他格式:视为无效,返回 null
249
+ *
250
+ * @param modelSpec ClassifierConfig.model('auto' 或 'provider/model-id')
251
+ * @param data 可选,预加载的 models.json(避免重复读盘);未传则 loadModelsJson()
252
+ * @param onWarning 可选警告回调(仅传给 loadModelsJson)
253
+ */
254
+ export function resolveClassifierModel(
255
+ modelSpec: string,
256
+ data?: ModelsJsonFile | null,
257
+ onWarning?: (msg: string) => void,
258
+ ): ResolvedModel | null {
259
+ const file = data !== undefined ? data : loadModelsJson(onWarning);
260
+ if (file === null) return null;
261
+
262
+ if (modelSpec === "auto") {
263
+ const cheapest = findCheapestModel(file);
264
+ if (cheapest === null) return null;
265
+ return {
266
+ provider: cheapest.provider,
267
+ id: cheapest.id,
268
+ api: cheapest.api,
269
+ name: cheapest.name,
270
+ baseUrl: cheapest.baseUrl,
271
+ inputCost: cheapest.cost.input,
272
+ apiKey: cheapest.apiKey,
273
+ };
274
+ }
275
+
276
+ // 'provider/model-id' 拆分查找
277
+ const slashIdx = modelSpec.indexOf("/");
278
+ if (slashIdx <= 0 || slashIdx === modelSpec.length - 1) {
279
+ return null;
280
+ }
281
+ const provider = modelSpec.slice(0, slashIdx);
282
+ const modelId = modelSpec.slice(slashIdx + 1);
283
+ const entries = flattenModels(file);
284
+ const found = entries.find((m) => m.provider === provider && m.id === modelId);
285
+ if (found === undefined) return null;
286
+ return {
287
+ provider: found.provider,
288
+ id: found.id,
289
+ api: found.api,
290
+ name: found.name,
291
+ baseUrl: found.baseUrl,
292
+ inputCost: found.cost.input,
293
+ apiKey: found.apiKey,
294
+ };
295
+ }