@zhushanwen/pi-permission 1.1.0 → 1.2.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-permission",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Pi permission extension — four permission modes (yolo/auto/approve/strict) with three-layer pipeline (AST + rules + AI classifier).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -29,7 +29,7 @@
29
29
  "dependencies": {
30
30
  "tree-sitter-bash": "^0.25.1",
31
31
  "web-tree-sitter": "^0.26.11",
32
- "@zhushanwen/pi-llm-shared": "0.2.0"
32
+ "@zhushanwen/pi-llm-shared": "0.3.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "@earendil-works/pi-ai": "*",
@@ -48,6 +48,7 @@ interface ClassifierConfig {
48
48
  timeout: number; // 超时秒,默认 90
49
49
  autoApproveLowRisk: boolean; // 低风险自动放行,默认 true
50
50
  autoDenyHighRisk: boolean; // 高风险转人工审批,默认 true
51
+ thinkingLevel: ModelThinkingLevel; // thinking 级别,默认 "off"(直接透传,llm-shared 映射为不传);"minimal"~"max" 透传
51
52
  }
52
53
  ```
53
54
 
@@ -64,8 +65,8 @@ interface ClassifierConfig {
64
65
 
65
66
  ### classifier.model(只接受 string)
66
67
 
67
- - `"auto"`(默认)→ scoped:读 settings.json enabledModels 取首个可用;空则 fallback available(getAvailable 首个)
68
- - `"provider/model-id"`(如 `"zhipu/glm-4-flash"`)→ 精确匹配
68
+ - `"auto"`(默认)→ permission 本地取 `ctx.modelRegistry.getAvailable()` 首个(不经过 llm-shared 的非精确 selector)
69
+ - `"provider/model-id"`(如 `"zhipu/glm-4-flash"`)→ 精确匹配(llm-shared `ModelSelector` 仅支持 ref)
69
70
  - 传对象形式会被 console.warn 忽略,回落 auto
70
71
 
71
72
  ### auto 模式的风险判定(autoApproveLowRisk / autoDenyHighRisk)
@@ -91,7 +92,8 @@ AI classifier 输出三档风险,对应处理:
91
92
  "model": "auto",
92
93
  "timeout": 90,
93
94
  "autoApproveLowRisk": true,
94
- "autoDenyHighRisk": true
95
+ "autoDenyHighRisk": true,
96
+ "thinkingLevel": "off"
95
97
  },
96
98
  "userRules": []
97
99
  }
@@ -91,16 +91,10 @@ beforeEach(() => {
91
91
 
92
92
  // ──────────────────────── TC1/TC2: toSelector 映射 ────────────────────────
93
93
 
94
- describe("toSelector(C2:'auto'→scoped 向后兼容)", () => {
95
- it("TC1: 'auto' 映射为 scoped", () => {
96
- expect(toSelector("auto")).toEqual({ type: "scoped" });
97
- });
98
-
99
- it("TC2: 'provider/model-id' 映射为 ref", () => {
94
+ describe("toSelector(仅 ref 精确指定)", () => {
95
+ it("任意字符串都映射为 ref(auto 由 production resolveModel 层自行处理,不走 selector)", () => {
96
+ expect(toSelector("auto")).toEqual({ type: "ref", ref: "auto" });
100
97
  expect(toSelector("anthropic/claude-sonnet")).toEqual({ type: "ref", ref: "anthropic/claude-sonnet" });
101
- });
102
-
103
- it("任意非 'auto' 字符串都视为 ref(含边角格式,由 resolveRef 判合法性)", () => {
104
98
  expect(toSelector("openai/gpt-4o")).toEqual({ type: "ref", ref: "openai/gpt-4o" });
105
99
  });
106
100
  });
@@ -129,16 +123,17 @@ describe("createProductionClassifier", () => {
129
123
 
130
124
  describe("resolveModel 装配(resolveModel + callLLM)", () => {
131
125
  const CTX = { toolName: "bash", command: "ls", cwd: "/tmp" };
132
- const CFG = { enabled: true, model: "auto", timeout: 5, autoApproveLowRisk: false, autoDenyHighRisk: true };
126
+ const CFG_AUTO = { enabled: true, model: "auto", timeout: 5, autoApproveLowRisk: false, autoDenyHighRisk: true };
127
+ const CFG_REF = { enabled: true, model: "test-co/model-a", timeout: 5, autoApproveLowRisk: false, autoDenyHighRisk: true };
133
128
 
134
- it("TC3: scoped enabledModels 空 + available 空 → resolveModel null → fail-closed ask(不触达 callLLM)", async () => {
135
- vi.mocked(resolveModelShared).mockReturnValue(null);
136
- const ctx = makeMockCtx();
129
+ it("TC3: auto 且 getAvailable 空 → resolveModel null → fail-closed ask(不触达 callLLM)", async () => {
130
+ const ctx = makeMockCtx({ getAvailable: () => [] });
137
131
  const classifier = createProductionClassifier(ctx);
138
- const result = await classifier.classifyRisk(CTX, CFG);
132
+ const result = await classifier.classifyRisk(CTX, CFG_AUTO);
139
133
  expect(result.outcome).toBe("ask");
140
134
  expect(result.confidence).toBe(0);
141
- // 关键:resolveModel null 时不应触达 callLLM(LLM 调用层)
135
+ // auto 是 permission 本地解析,不经过 llm-shared resolveModel
136
+ expect(resolveModelShared).not.toHaveBeenCalled();
142
137
  expect(callLLMShared).not.toHaveBeenCalled();
143
138
  });
144
139
 
@@ -154,16 +149,14 @@ describe("resolveModel 装配(resolveModel + callLLM)", () => {
154
149
  expect(source).toContain('from "@zhushanwen/pi-llm-shared"');
155
150
  });
156
151
 
157
- it("TC4(装配): resolveModel 命中 → callLLM 收到 model → ok:true 正常分类(收口链路通)", async () => {
158
- vi.mocked(resolveModelShared).mockImplementation((_ctx, selector) =>
159
- selector.type === "scoped" ? MOCK_MODEL_A : null,
160
- );
152
+ it("TC4(装配): ref resolveModel 命中 → callLLM 收到 model → ok:true 正常分类(收口链路通)", async () => {
153
+ vi.mocked(resolveModelShared).mockReturnValue(MOCK_MODEL_A);
161
154
  vi.mocked(callLLMShared).mockResolvedValue({
162
155
  ok: true,
163
156
  content: '{"outcome":"allow","risk_level":"low","reasoning":"safe","confidence":0.9}',
164
157
  });
165
158
  const classifier = createProductionClassifier(makeMockCtx());
166
- const result = await classifier.classifyRisk(CTX, CFG);
159
+ const result = await classifier.classifyRisk(CTX, CFG_REF);
167
160
  expect(result.outcome).toBe("allow");
168
161
  // 关键:resolveModel 返回 model 后,callLLM 被调用且收到该 model(收口链路 resolve→callLLM 通)
169
162
  expect(callLLMShared).toHaveBeenCalledOnce();
@@ -172,19 +165,18 @@ describe("resolveModel 装配(resolveModel + callLLM)", () => {
172
165
  expect(opts.model).toBe(MOCK_MODEL_A);
173
166
  expect(opts.messages).toHaveLength(1);
174
167
  expect(opts.systemPrompt.length).toBeGreaterThan(0);
175
- // scoped 命中只调 1 次 resolveModel(不走 available fallback)
168
+ // ref 精确指定只调 1 次 resolveModel
176
169
  expect(vi.mocked(resolveModelShared)).toHaveBeenCalledTimes(1);
170
+ expect(vi.mocked(resolveModelShared)).toHaveBeenCalledWith(expect.anything(), { type: "ref", ref: "test-co/model-a" });
177
171
  });
178
172
 
179
- it("TC4b: resolveModel 成功(scoped 命中)→ onLog 输出 '[pi-permission] classifier: using model <modelId>'(契约 C2,R2 验收前提)", async () => {
180
- vi.mocked(resolveModelShared).mockImplementation((_ctx, selector) =>
181
- selector.type === "scoped" ? MOCK_MODEL_A : null,
182
- );
173
+ it("TC4b: ref resolveModel 成功 → onLog 输出 '[pi-permission] classifier: using model <modelId>'(契约 C2,R2 验收前提)", async () => {
174
+ vi.mocked(resolveModelShared).mockReturnValue(MOCK_MODEL_A);
183
175
  vi.mocked(callLLMShared).mockResolvedValue({ ok: true, content: "x" });
184
176
  const classifier = createProductionClassifier(makeMockCtx());
185
177
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
186
178
  try {
187
- await classifier.classifyRisk(CTX, CFG);
179
+ await classifier.classifyRisk(CTX, CFG_REF);
188
180
  // onLog 实装为 console.warn(production.ts),成功路径日志在 LLM 调用前输出
189
181
  expect(warnSpy).toHaveBeenCalledWith("[pi-permission] classifier: using model model-a");
190
182
  } finally {
@@ -196,26 +188,22 @@ describe("resolveModel 装配(resolveModel + callLLM)", () => {
196
188
  vi.mocked(resolveModelShared).mockReturnValue(MOCK_MODEL_A);
197
189
  vi.mocked(callLLMShared).mockResolvedValue({ ok: false, error: "token expired", recoverable: true });
198
190
  const classifier = createProductionClassifier(makeMockCtx());
199
- const result = await classifier.classifyRisk(CTX, CFG);
191
+ const result = await classifier.classifyRisk(CTX, CFG_REF);
200
192
  expect(result.outcome).toBe("ask");
201
193
  expect(result.confidence).toBe(0);
202
194
  expect(callLLMShared).toHaveBeenCalledOnce();
203
195
  });
204
196
 
205
- it("TC7: scoped 空 fallback available 命中(CL-scoped-fallback:有 auth provider 但无 enabledModels 不退化)", async () => {
206
- // scoped(首次)返回 null,available(fallback)返回 MOCK_MODEL_A
207
- vi.mocked(resolveModelShared).mockImplementation((_ctx, selector) =>
208
- selector.type === "available" ? MOCK_MODEL_A : null,
209
- );
197
+ it("TC7: auto 直接取 getAvailable 首个(permission 本地兼容,不经过非精确 ModelSelector)", async () => {
198
+ const ctx = makeMockCtx({ getAvailable: () => [MOCK_MODEL_A] });
210
199
  vi.mocked(callLLMShared).mockResolvedValue({ ok: true, content: "x" });
211
- const classifier = createProductionClassifier(makeMockCtx());
212
- const result = await classifier.classifyRisk(CTX, CFG);
213
- // fallback 命中 available → callLLM 收到 available model(非 scoped 的 null 直接返回)
200
+ const classifier = createProductionClassifier(ctx);
201
+ const result = await classifier.classifyRisk(CTX, CFG_AUTO);
214
202
  expect(callLLMShared).toHaveBeenCalledOnce();
215
203
  const opts = vi.mocked(callLLMShared).mock.calls[0]![1];
216
204
  expect(opts.model).toBe(MOCK_MODEL_A);
217
- // scoped 调 1 次(null)+ available fallback 调 1 次 = 2 次
218
- expect(vi.mocked(resolveModelShared)).toHaveBeenCalledTimes(2);
205
+ // auto 不经过 llm-shared resolveModel
206
+ expect(resolveModelShared).not.toHaveBeenCalled();
219
207
  // callLLM ok:true 但 content 非 JSON → parser fallback ask(链路已证明 resolve→callLLM 非 null)
220
208
  expect(result.outcome).toBe("ask");
221
209
  });
@@ -157,6 +157,8 @@ export function createClassifier(deps: ClassifierDeps): {
157
157
  messages: buildMessages(ctx),
158
158
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
159
159
  ...(signal !== undefined ? { signal } : {}),
160
+ // thinkingLevel 直接透传(含 "off");llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)
161
+ reasoning: config.thinkingLevel,
160
162
  });
161
163
 
162
164
  // 3. 等待 callLLM + 外层超时/中止兜底(provider 不支持 timeoutMs/signal 时防永挂)
package/src/config.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
18
18
  import { dirname, join } from "node:path";
19
19
 
20
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
20
21
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
21
22
  import {
22
23
  clearConfigCache,
@@ -50,6 +51,20 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
50
51
  return typeof value === "object" && value !== null && !Array.isArray(value);
51
52
  }
52
53
 
54
+ const THINKING_LEVELS: ReadonlySet<string> = new Set([
55
+ "off",
56
+ "minimal",
57
+ "low",
58
+ "medium",
59
+ "high",
60
+ "xhigh",
61
+ "max",
62
+ ]);
63
+
64
+ function isModelThinkingLevel(value: unknown): value is ModelThinkingLevel {
65
+ return typeof value === "string" && THINKING_LEVELS.has(value);
66
+ }
67
+
53
68
  function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
54
69
  const record = isPlainObject(raw) ? raw : {};
55
70
  const timeout = Number(record.timeout);
@@ -58,12 +73,17 @@ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
58
73
  if (record.model !== undefined && !(typeof record.model === "string" && record.model.length > 0)) {
59
74
  console.warn("[pi-permission] Ignoring invalid classifier.model (expected string 'auto' or 'provider/model-id'), using default auto");
60
75
  }
76
+ // thinkingLevel 校验:合法值为 'off'|'minimal'|'low'|'medium'|'high'|'xhigh'|'max'
77
+ const thinkingLevel = isModelThinkingLevel(record.thinkingLevel)
78
+ ? record.thinkingLevel
79
+ : DEFAULT_CLASSIFIER_CONFIG.thinkingLevel;
61
80
  return {
62
81
  enabled: record.enabled !== false,
63
82
  model: typeof record.model === "string" && record.model.length > 0 ? record.model : DEFAULT_CLASSIFIER_CONFIG.model,
64
83
  timeout: Number.isFinite(timeout) && timeout > 0 ? timeout : DEFAULT_CLASSIFIER_CONFIG.timeout,
65
84
  autoApproveLowRisk: record.autoApproveLowRisk !== false,
66
85
  autoDenyHighRisk: record.autoDenyHighRisk !== false,
86
+ thinkingLevel,
67
87
  };
68
88
  }
69
89
 
package/src/production.ts CHANGED
@@ -27,20 +27,15 @@ import { createClassifier } from "./classifier/index.js";
27
27
  import type { CheckPermissionDeps } from "./pipeline.js";
28
28
  import { getDefaultRules, matchRulesForArgv } from "./rules/index.js";
29
29
 
30
- // ──────────────────────── toSelector('auto'→scoped 向后兼容,C2) ────────────────────────
30
+ // ──────────────────────── toSelector(仅 ref 精确指定) ────────────────────────
31
31
 
32
32
  /**
33
33
  * 把 ClassifierConfig.model 字符串映射为 llm-shared ModelSelector。
34
34
  *
35
- * - 'auto' → { type: 'scoped' }:读 settings.json enabledModels 取首个可用 model
36
- * (用户显式排序),取代旧的 findCheapestModel(cost 排序)。
37
- * - 其余视为 'provider/model-id' → { type: 'ref', ref: modelSpec }:精确匹配。
38
- *
39
- * 注:toSelector 只负责 selector 映射;'auto' 的向后兼容(scoped 空 fallback available)
40
- * 在下方 resolveModel 注入层处理(CL-scoped-fallback),toSelector 本身不感知 fallback。
35
+ * llm-shared 只支持 ref 精确指定;permission 的 "auto" 在 resolveModel 注入层自行处理,
36
+ * 不通过 ModelSelector 表达。
41
37
  */
42
38
  export function toSelector(modelSpec: string): ModelSelector {
43
- if (modelSpec === "auto") return { type: "scoped" };
44
39
  return { type: "ref", ref: modelSpec };
45
40
  }
46
41
 
@@ -49,12 +44,11 @@ export function toSelector(modelSpec: string): ModelSelector {
49
44
  /**
50
45
  * 创建生产环境 AI Classifier。
51
46
  *
52
- * resolveModel 注入(C3,含 scoped-null fallback available CL-scoped-fallback):
53
- * 1. toSelector(config.model) → selector
54
- * 2. resolveModelShared(ctx, selector) → model(scoped 走 enabledModels,ref 走 find + hasConfiguredAuth)
55
- * 3. scoped 返回 null → fallback resolveModelShared(ctx, {type:'available'})(getAvailable 首个,
56
- * 等价旧 auto「有 apiKey 的首个」,向后兼容)
57
- * 4. model 仍 null → 返回 null(fail-closed)
47
+ * resolveModel 注入:
48
+ * - config.model === "auto":直接取 ctx.modelRegistry.getAvailable()[0](permission 本地
49
+ * 向后兼容行为,不经过 llm-shared 的非精确 selector)。
50
+ * - 其余:toSelector(config.model) → resolveModelShared(ctx, selector)(仅 ref 精确匹配)。
51
+ * - model 仍 null → 返回 null(fail-closed)。
58
52
  *
59
53
  * callLLM 注入(C1a):闭包捕获 ctx,把 llm-shared callLLM 绑定为 classifier 的 LLM 调用。
60
54
  * 凭证获取(getApiKeyAndHeaders)在 callLLM 内部完成(C1a 收口后不再在 resolveModel 层预检)。
@@ -64,14 +58,11 @@ export function createProductionClassifier(ctx: ExtensionContext): {
64
58
  } {
65
59
  return createClassifier({
66
60
  resolveModel: async (config) => {
67
- const selector = toSelector(config.model);
68
- let model = resolveModelShared(ctx, selector);
69
- // CL-scoped-fallback:scoped('auto')在 enabledModels 空/无 auth 时 fallback available,
70
- // 保证「有 apiKey provider 但没配 enabledModels」的用户不退化(旧 auto 行为)。
71
- if (!model && selector.type === "scoped") {
72
- model = resolveModelShared(ctx, { type: "available" });
61
+ if (config.model === "auto") {
62
+ const available = ctx.modelRegistry.getAvailable();
63
+ return available.length > 0 ? available[0] : null;
73
64
  }
74
- return model;
65
+ return resolveModelShared(ctx, toSelector(config.model));
75
66
  },
76
67
  callLLM: (opts) => callLLM(ctx, opts),
77
68
  onLog: (msg: string) => console.warn(msg),
package/src/types.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * 对应 slice plan 的 DM1-DM5 数据模型。
6
6
  */
7
7
 
8
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
9
+
8
10
  // ──────────────────────── DM1: PermissionMode ────────────────────────
9
11
 
10
12
  /** 四档权限模式,按严格等级排序(yolo 最低,strict 最高) */
@@ -96,6 +98,12 @@ export interface ClassifierConfig {
96
98
  autoApproveLowRisk: boolean;
97
99
  /** 高风险是否自动拦截(转人工审批) */
98
100
  autoDenyHighRisk: boolean;
101
+ /**
102
+ * 标题生成 LLM 的 thinking 级别(pi 的 ModelThinkingLevel,THINKING_ORDER SSOT)。
103
+ * 默认 "off":不传 pi-ai reasoning(provider 默认行为);
104
+ * "minimal"~"max" 透传给 SimpleStreamOptions.reasoning(provider 不支持时静默忽略)。
105
+ */
106
+ thinkingLevel: ModelThinkingLevel;
99
107
  }
100
108
 
101
109
  // ──────────────────────── DM5: PermissionConfig ────────────────────────
@@ -120,6 +128,7 @@ export const DEFAULT_CLASSIFIER_CONFIG: ClassifierConfig = {
120
128
  timeout: 90,
121
129
  autoApproveLowRisk: true,
122
130
  autoDenyHighRisk: true,
131
+ thinkingLevel: "off",
123
132
  };
124
133
 
125
134
  export const DEFAULT_CONFIG: PermissionConfig = {