@zhushanwen/pi-llm-shared 0.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/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@zhushanwen/pi-llm-shared",
3
+ "version": "0.2.0",
4
+ "description": "Shared LLM invocation library for Pi extensions — model resolution (ref/fallback/available/scoped), LLM calling (completeSimple), and config read/write with mtime caching. Shared library, not a Pi extension.",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "keywords": [
8
+ "pi-package",
9
+ "pi",
10
+ "llm",
11
+ "shared",
12
+ "model-resolution",
13
+ "config"
14
+ ],
15
+ "license": "MIT",
16
+ "files": [
17
+ "src/",
18
+ "index.ts"
19
+ ],
20
+ "peerDependencies": {
21
+ "@earendil-works/pi-ai": "*",
22
+ "@earendil-works/pi-coding-agent": "*"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "@earendil-works/pi-ai": {
26
+ "optional": true
27
+ },
28
+ "@earendil-works/pi-coding-agent": {
29
+ "optional": true
30
+ }
31
+ },
32
+ "devDependencies": {
33
+ "@earendil-works/pi-ai": "*",
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "vitest": "^4.1.8"
36
+ },
37
+ "scripts": {
38
+ "typecheck": "npx tsc --noEmit",
39
+ "test": "vitest run"
40
+ }
41
+ }
@@ -0,0 +1,209 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { callLLM, extractText } from "../call.ts";
7
+
8
+ // mock completeSimple —— call.ts 顶层静态 import 会拿到此 mock(探针①已验证静态 import 机制可行,
9
+ // 此处验证 callLLM 逻辑:凭证 narrow / options 构造 / 文本提取 / 错误归一化)。
10
+ vi.mock("@earendil-works/pi-ai/compat", () => ({
11
+ completeSimple: vi.fn(),
12
+ }));
13
+
14
+ const mockComplete = vi.mocked(completeSimple);
15
+
16
+ function makeModel(): Model<Api> {
17
+ return { id: "m", provider: "p", name: "m", api: "anthropic" as Api, baseUrl: "", reasoning: false } as unknown as Model<Api>;
18
+ }
19
+
20
+ function makeCtx(authResult: unknown): ExtensionContext {
21
+ return {
22
+ modelRegistry: { getApiKeyAndHeaders: vi.fn(async () => authResult) },
23
+ } as unknown as ExtensionContext;
24
+ }
25
+
26
+ beforeEach(() => {
27
+ mockComplete.mockReset();
28
+ });
29
+
30
+ describe("callLLM", () => {
31
+ it("TC11 成功:提取 text(trim)+ tools 传 []", async () => {
32
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
33
+ mockComplete.mockResolvedValue({ content: [{ type: "text", text: " hello " }] });
34
+
35
+ const result = await callLLM(ctx, {
36
+ model: makeModel(),
37
+ systemPrompt: "s",
38
+ messages: [],
39
+ sessionId: "sess-1",
40
+ });
41
+
42
+ expect(result).toEqual({ ok: true, content: "hello" });
43
+ // 验证 completeSimple 被调用,第二参数 context 含 tools:[],第三参数 options 含 apiKey + sessionId 透传
44
+ expect(mockComplete).toHaveBeenCalledTimes(1);
45
+ const [, contextArg, optionsArg] = mockComplete.mock.calls[0];
46
+ expect(contextArg).toMatchObject({ systemPrompt: "s", messages: [], tools: [] });
47
+ expect(optionsArg).toMatchObject({ apiKey: "k", sessionId: "sess-1" });
48
+ });
49
+
50
+ it("TC12 auth-fail → {ok:false, recoverable:true},不调 completeSimple(narrow 不取 apiKey)", async () => {
51
+ const ctx = makeCtx({ ok: false, error: "no key" });
52
+
53
+ const result = await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
54
+
55
+ expect(result).toEqual({ ok: false, error: "no key", recoverable: true });
56
+ expect(mockComplete).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it("TC13 completeSimple throw → {ok:false, recoverable:true, error 含错误信息}", async () => {
60
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
61
+ mockComplete.mockRejectedValue(new Error("network timeout"));
62
+
63
+ const result = await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
64
+
65
+ expect(result.ok).toBe(false);
66
+ expect(result).toMatchObject({ recoverable: true, error: expect.stringContaining("network") });
67
+ });
68
+
69
+ it("TC1 stopReason=error → {ok:false, error, recoverable:true, stopReason:'error'}(不再 ok:true 返回错误文本)", async () => {
70
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
71
+ // completeSimple 对错误也 resolve(带 stopReason),content 是错误文本
72
+ mockComplete.mockResolvedValue({ stopReason: "error", content: [{ type: "text", text: "API error: 429 rate limited" }] });
73
+
74
+ const result = await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
75
+
76
+ expect(result).toEqual({ ok: false, error: "API error: 429 rate limited", recoverable: true, stopReason: "error" });
77
+ });
78
+
79
+ it("TC2 stopReason=aborted → {ok:false, error, recoverable:true, stopReason:'aborted'}", async () => {
80
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
81
+ mockComplete.mockResolvedValue({ stopReason: "aborted", content: [{ type: "text", text: "user aborted" }] });
82
+
83
+ const result = await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
84
+
85
+ expect(result).toEqual({ ok: false, error: "user aborted", recoverable: true, stopReason: "aborted" });
86
+ });
87
+
88
+ it("TC3 stopReason=stop(正常)→ 不受 stopReason 检查影响,ok:true 提取文本", async () => {
89
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
90
+ mockComplete.mockResolvedValue({ stopReason: "stop", content: [{ type: "text", text: " hello " }] });
91
+
92
+ const result = await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
93
+
94
+ expect(result).toEqual({ ok: true, content: "hello" });
95
+ });
96
+
97
+ it("stopReason=error 且 content 无 text → error 回落 'unknown error'", async () => {
98
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
99
+ mockComplete.mockResolvedValue({ stopReason: "error", content: [] });
100
+
101
+ const result = await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
102
+
103
+ expect(result).toEqual({ ok: false, error: "unknown error", recoverable: true, stopReason: "error" });
104
+ });
105
+
106
+ it("TC13 catch 路径不设 stopReason(错误原因不可知)", async () => {
107
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
108
+ mockComplete.mockRejectedValue(new Error("boom"));
109
+
110
+ const result = await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
111
+
112
+ expect(result.ok).toBe(false);
113
+ if (result.ok === false) {
114
+ expect(result.stopReason).toBeUndefined();
115
+ }
116
+ });
117
+
118
+ it("review TF1: sessionId 透传到 options 第三参数", async () => {
119
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
120
+ mockComplete.mockResolvedValue({ content: [{ type: "text", text: "x" }] });
121
+
122
+ await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [], sessionId: "abc-123" });
123
+
124
+ const optionsArg = mockComplete.mock.calls[0][2];
125
+ expect(optionsArg).toMatchObject({ sessionId: "abc-123" });
126
+ });
127
+
128
+ it("review TF1: 无 sessionId 时 options 不含 sessionId 字段(条件 spread 不传)", async () => {
129
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
130
+ mockComplete.mockResolvedValue({ content: [{ type: "text", text: "x" }] });
131
+
132
+ await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
133
+
134
+ const optionsArg = mockComplete.mock.calls[0][2] as Record<string, unknown>;
135
+ expect("sessionId" in optionsArg).toBe(false);
136
+ });
137
+
138
+ it("B5: getApiKeyAndHeaders reject(抛异常)→ {ok:false, recoverable:true}(归一入 catch,不向上抛)", async () => {
139
+ const getApiKeyAndHeaders = vi.fn().mockRejectedValueOnce(new Error("registry exploded"));
140
+ const ctx = { modelRegistry: { getApiKeyAndHeaders } } as unknown as ExtensionContext;
141
+
142
+ const result = await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
143
+
144
+ expect(result).toEqual({ ok: false, error: "registry exploded", recoverable: true });
145
+ // 凭证阶段就 reject,completeSimple 未被调用
146
+ expect(mockComplete).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("review C2: signal 透传到 options 第三参数", async () => {
150
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
151
+ mockComplete.mockResolvedValue({ content: [{ type: "text", text: "x" }] });
152
+ const ac = new AbortController();
153
+
154
+ await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [], signal: ac.signal });
155
+
156
+ const optionsArg = mockComplete.mock.calls[0][2];
157
+ expect(optionsArg).toMatchObject({ signal: ac.signal });
158
+ });
159
+
160
+ it("review C2: maxTokens 透传到 options 第三参数", async () => {
161
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
162
+ mockComplete.mockResolvedValue({ content: [{ type: "text", text: "x" }] });
163
+
164
+ await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [], maxTokens: 1024 });
165
+
166
+ const optionsArg = mockComplete.mock.calls[0][2];
167
+ expect(optionsArg).toMatchObject({ maxTokens: 1024 });
168
+ });
169
+
170
+ it("review C2: timeoutMs 透传到 options 第三参数", async () => {
171
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
172
+ mockComplete.mockResolvedValue({ content: [{ type: "text", text: "x" }] });
173
+
174
+ await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [], timeoutMs: 5000 });
175
+
176
+ const optionsArg = mockComplete.mock.calls[0][2];
177
+ expect(optionsArg).toMatchObject({ timeoutMs: 5000 });
178
+ });
179
+
180
+ it("review C2: 不传 signal/maxTokens/timeoutMs 时 options 不含这些字段(条件 spread)", async () => {
181
+ const ctx = makeCtx({ ok: true, apiKey: "k" });
182
+ mockComplete.mockResolvedValue({ content: [{ type: "text", text: "x" }] });
183
+
184
+ await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
185
+
186
+ const optionsArg = mockComplete.mock.calls[0][2] as Record<string, unknown>;
187
+ expect("signal" in optionsArg).toBe(false);
188
+ expect("maxTokens" in optionsArg).toBe(false);
189
+ expect("timeoutMs" in optionsArg).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe("extractText", () => {
194
+ it("TC11 单个 text block 提取 + trim", () => {
195
+ expect(extractText({ content: [{ type: "text", text: " hello " }] })).toBe("hello");
196
+ });
197
+
198
+ it("review: 多个 text block 拼接 + trim", () => {
199
+ expect(extractText({ content: [{ type: "text", text: "a" }, { type: "text", text: "b" }] })).toBe("a b");
200
+ });
201
+
202
+ it("review: 无 text block(纯 ThinkingContent / ToolCall)→ ''", () => {
203
+ expect(extractText({ content: [{ type: "thinking", text: "..." }, { type: "tool_call" }] })).toBe("");
204
+ });
205
+
206
+ it("review: 空 content → ''", () => {
207
+ expect(extractText({ content: [] })).toBe("");
208
+ });
209
+ });
@@ -0,0 +1,217 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
2
+ import * as fs from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ import { clearConfigCache, getConfigPath, loadConfig, saveConfig } from "../config.ts";
8
+
9
+ // node:fs 的 ESM namespace 不可配置,vi.spyOn 对具名导出失效(vitest 限制)。
10
+ // 用 vi.mock 包装 readFileSync/renameSync(默认走 actual,个别 test override),
11
+ // 其他 fs 操作(writeFileSync/existsSync/statSync/mkdirSync...)原样透传 actual。
12
+ vi.mock("node:fs", async (importOriginal) => {
13
+ const actual = await importOriginal() as typeof import("node:fs");
14
+ return {
15
+ ...actual,
16
+ readFileSync: vi.fn(actual.readFileSync),
17
+ renameSync: vi.fn(actual.renameSync),
18
+ };
19
+ });
20
+
21
+ let dir: string;
22
+
23
+ beforeEach(() => {
24
+ dir = mkdtempSync(join(tmpdir(), "llm-shared-cfg-"));
25
+ vi.stubEnv("PI_CODING_AGENT_DIR", dir);
26
+ clearConfigCache();
27
+ // 预创建 config/ 子目录(loadConfig fixture 直接 writeFileSync 需要目录已存在;
28
+ // saveConfig 内部会自己 mkdir,但 loadConfig fixture 不会)
29
+ mkdirSync(join(dir, "config"), { recursive: true });
30
+ });
31
+
32
+ afterEach(() => {
33
+ vi.mocked(fs.readFileSync).mockClear();
34
+ vi.mocked(fs.renameSync).mockClear();
35
+ rmSync(dir, { recursive: true, force: true });
36
+ vi.unstubAllEnvs();
37
+ });
38
+
39
+ /** 测试用 normalize:对象含 a → 原样,否则默认 {a:0}。 */
40
+ const normalize = (raw: unknown): { a: number } => {
41
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw) && "a" in raw) {
42
+ return { a: (raw as { a: number }).a };
43
+ }
44
+ return { a: 0 };
45
+ };
46
+
47
+ describe("getConfigPath", () => {
48
+ it("TC17 走 getAgentDir(PI_CODING_AGENT_DIR 覆盖生效)", () => {
49
+ expect(getConfigPath("rename-session")).toBe(join(dir, "config", "rename-session-ext-config.json"));
50
+ });
51
+ });
52
+
53
+ describe("loadConfig", () => {
54
+ it("TC14 文件存在 → 解析 + normalize", () => {
55
+ writeFileSync(join(dir, "config", "test-ext-config.json"), JSON.stringify({ a: 1 }));
56
+ expect(loadConfig("test", { a: 0 }, normalize)).toEqual({ a: 1 });
57
+ });
58
+
59
+ it("TC14 mtime+size 不变 → 命中缓存(readFileSync 只调一次)", () => {
60
+ writeFileSync(join(dir, "config", "test-ext-config.json"), JSON.stringify({ a: 1 }));
61
+ vi.mocked(fs.readFileSync).mockClear();
62
+
63
+ const r1 = loadConfig("test", { a: 0 }, normalize);
64
+ const r2 = loadConfig("test", { a: 0 }, normalize);
65
+
66
+ expect(r1).toEqual({ a: 1 });
67
+ expect(r2).toEqual({ a: 1 });
68
+ // statSync 命中缓存,readFileSync 只调一次
69
+ expect(vi.mocked(fs.readFileSync)).toHaveBeenCalledTimes(1);
70
+ });
71
+
72
+ it("TC14 缓存返回值是深拷贝(改返回值不污染缓存)", () => {
73
+ writeFileSync(join(dir, "config", "test-ext-config.json"), JSON.stringify({ a: 1 }));
74
+ const r1 = loadConfig("test", { a: 0 }, normalize);
75
+ r1.a = 999; // 篡改返回值
76
+ const r2 = loadConfig("test", { a: 0 }, normalize);
77
+ expect(r2).toEqual({ a: 1 }); // 缓存未被污染
78
+ });
79
+
80
+ it("TC15 文件不存在 → defaults", () => {
81
+ expect(loadConfig("missing", { a: 0 }, normalize)).toEqual({ a: 0 });
82
+ });
83
+
84
+ it("TC15 坏 JSON → defaults + onWarning 回调", () => {
85
+ writeFileSync(join(dir, "config", "bad-ext-config.json"), "{not json");
86
+ const onWarning = vi.fn();
87
+ expect(loadConfig("bad", { a: 0 }, normalize, onWarning)).toEqual({ a: 0 });
88
+ expect(onWarning).toHaveBeenCalledTimes(1);
89
+ });
90
+
91
+ it("C1: mtime 变化(size 不变)→ 重新 readFileSync(缓存失效重读)", () => {
92
+ const cfgPath = join(dir, "config", "test-ext-config.json");
93
+ // v1: {"a":1}(7 字节),mtime 固定 1s → mtimeMs=1000
94
+ writeFileSync(cfgPath, JSON.stringify({ a: 1 }));
95
+ utimesSync(cfgPath, 1, 1);
96
+ loadConfig("test", { a: 0 }, normalize); // 首次读,cache=(1000ms, 7)
97
+
98
+ // v2: {"a":2}(仍 7 字节,size 不变),mtime 改为 2s → mtimeMs=2000
99
+ writeFileSync(cfgPath, JSON.stringify({ a: 2 }));
100
+ utimesSync(cfgPath, 2, 2);
101
+
102
+ vi.mocked(fs.readFileSync).mockClear();
103
+ const loaded = loadConfig("test", { a: 0 }, normalize);
104
+
105
+ expect(loaded).toEqual({ a: 2 });
106
+ // mtime 变化 → 缓存失效 → 重读
107
+ expect(vi.mocked(fs.readFileSync)).toHaveBeenCalledTimes(1);
108
+ });
109
+
110
+ it("C1: size 变化但 mtime 不变(APFS 截断模拟)→ 触发重读(双 key 设计核心验证)", () => {
111
+ const cfgPath = join(dir, "config", "test-ext-config.json");
112
+ // v1: {"a":1}(7 字节),mtime 固定 1s
113
+ writeFileSync(cfgPath, JSON.stringify({ a: 1 }));
114
+ utimesSync(cfgPath, 1, 1);
115
+ loadConfig("test", { a: 0 }, normalize); // cache=(1000ms, 7)
116
+
117
+ // v2: {"a":99}(8 字节,size 变),mtime 强制回 1s(模拟 APFS 精度截断:内容变了 mtime 没变)
118
+ writeFileSync(cfgPath, JSON.stringify({ a: 99 }));
119
+ utimesSync(cfgPath, 1, 1);
120
+
121
+ vi.mocked(fs.readFileSync).mockClear();
122
+ const loaded = loadConfig("test", { a: 0 }, normalize);
123
+
124
+ expect(loaded).toEqual({ a: 99 });
125
+ // size 变化 → 缓存失效 → 重读(即使 mtimeMs 相同,双 key 设计的核心价值)
126
+ expect(vi.mocked(fs.readFileSync)).toHaveBeenCalledTimes(1);
127
+ });
128
+ });
129
+
130
+ describe("saveConfig", () => {
131
+ it("TC16 原子写:文件落盘 + 内容正确 + 无 tmp 残留", () => {
132
+ const result = saveConfig("test", { b: 2 });
133
+ expect(result.success).toBe(true);
134
+
135
+ const cfgPath = join(dir, "config", "test-ext-config.json");
136
+ expect(existsSync(cfgPath)).toBe(true);
137
+ expect(JSON.parse(readFileSync(cfgPath, "utf-8"))).toEqual({ b: 2 });
138
+ expect(existsSync(`${cfgPath}.tmp`)).toBe(false); // 无 tmp 残留
139
+ });
140
+
141
+ it("TC16 文件 mode 0o600", () => {
142
+ saveConfig("test", { b: 2 });
143
+ const cfgPath = join(dir, "config", "test-ext-config.json");
144
+ const mode = statSync(cfgPath).mode & 0o777;
145
+ expect(mode).toBe(0o600);
146
+ });
147
+
148
+ it("TC16 写后 loadConfig 命中缓存返回新值(写后读竞态覆盖)", () => {
149
+ saveConfig("test", { a: 5 });
150
+ // saveConfig 写后已更新缓存,loadConfig 直接命中(不重读盘)
151
+ vi.mocked(fs.readFileSync).mockClear();
152
+ const loaded = loadConfig("test", { a: 0 }, normalize);
153
+ expect(loaded).toEqual({ a: 5 });
154
+ expect(vi.mocked(fs.readFileSync)).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("review RK3: renameSync throw → {success:false} + tmp 被清理", () => {
158
+ // mock renameSync 抛 EPERM,模拟 rename 失败(writeFileSync 已创建 tmp)
159
+ vi.mocked(fs.renameSync).mockImplementationOnce(() => {
160
+ throw new Error("EPERM: operation not permitted, rename");
161
+ });
162
+
163
+ const result = saveConfig("fail", { x: 1 });
164
+
165
+ expect(result.success).toBe(false);
166
+ expect(result.error).toContain("EPERM");
167
+ // tmp 文件被 catch 块的 unlinkSync 清理
168
+ expect(existsSync(join(dir, "config", "fail-ext-config.json.tmp"))).toBe(false);
169
+ // 目标文件未被创建(rename 失败)
170
+ expect(existsSync(join(dir, "config", "fail-ext-config.json"))).toBe(false);
171
+ });
172
+
173
+ it("探针 4: renameSync ENOENT → {success:false} + onWarning 含 Failed to save config + tmp 清理", () => {
174
+ // mock renameSync 抛 ENOENT(目标目录被删/路径无效场景)
175
+ vi.mocked(fs.renameSync).mockImplementationOnce(() => {
176
+ throw new Error("ENOENT: no such file or directory, rename");
177
+ });
178
+ const onWarning = vi.fn();
179
+
180
+ const result = saveConfig("enoent", { x: 1 }, onWarning);
181
+
182
+ expect(result.success).toBe(false);
183
+ expect(result.error).toContain("ENOENT");
184
+ // onWarning 输出前缀契约:`[llm-shared] Failed to save config at '<path>': <message>`
185
+ expect(onWarning).toHaveBeenCalledTimes(1);
186
+ const warning = String(onWarning.mock.calls[0][0]);
187
+ expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "enoent-ext-config.json") + "'");
188
+ expect(warning).toContain("ENOENT");
189
+ // tmp 清理 + 目标未创建
190
+ expect(existsSync(join(dir, "config", "enoent-ext-config.json.tmp"))).toBe(false);
191
+ expect(existsSync(join(dir, "config", "enoent-ext-config.json"))).toBe(false);
192
+ });
193
+
194
+ it("探针 4: renameSync EPERM → onWarning 输出契约 + {success:false}(Windows 目标占用模拟)", () => {
195
+ vi.mocked(fs.renameSync).mockImplementationOnce(() => {
196
+ throw new Error("EPERM: operation not permitted, rename");
197
+ });
198
+ const onWarning = vi.fn();
199
+
200
+ const result = saveConfig("eperm", { x: 1 }, onWarning);
201
+
202
+ expect(result.success).toBe(false);
203
+ expect(onWarning).toHaveBeenCalledTimes(1);
204
+ const warning = String(onWarning.mock.calls[0][0]);
205
+ expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "eperm-ext-config.json") + "'");
206
+ expect(warning).toContain("EPERM");
207
+ // tmp 清理(Windows 目标占用场景 rename 失败后 tmp 残留被清理)
208
+ expect(existsSync(join(dir, "config", "eperm-ext-config.json.tmp"))).toBe(false);
209
+ expect(existsSync(join(dir, "config", "eperm-ext-config.json"))).toBe(false);
210
+ });
211
+
212
+ it("saveConfig 多次写同文件 → 每次成功 + 最新内容", () => {
213
+ expect(saveConfig("test", { v: 1 }).success).toBe(true);
214
+ expect(saveConfig("test", { v: 2 }).success).toBe(true);
215
+ expect(JSON.parse(readFileSync(join(dir, "config", "test-ext-config.json"), "utf-8"))).toEqual({ v: 2 });
216
+ });
217
+ });
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, existsSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { migrateLegacyConfig } from "../migrate.ts";
6
+
7
+ describe("migrateLegacyConfig", () => {
8
+ let dir: string;
9
+ beforeEach(() => {
10
+ dir = mkdtempSync(join(tmpdir(), "pi-migrate-test-"));
11
+ });
12
+ afterEach(() => {
13
+ rmSync(dir, { recursive: true, force: true });
14
+ });
15
+
16
+ it("旧路径不存在 → noop(migrated: false,不动文件系统)", () => {
17
+ const result = migrateLegacyConfig(dir, "old.json", "config/new.json");
18
+ expect(result.migrated).toBe(false);
19
+ expect(existsSync(join(dir, "config/new.json"))).toBe(false);
20
+ });
21
+
22
+ it("旧路径存在 + 新路径不存在 → 原子搬移(旧消失、新出现、内容一致)", () => {
23
+ writeFileSync(join(dir, "old.json"), '{"mode":"strict"}', "utf-8");
24
+ const result = migrateLegacyConfig(dir, "old.json", "config/new.json");
25
+ expect(result.migrated).toBe(true);
26
+ expect(existsSync(join(dir, "old.json"))).toBe(false);
27
+ expect(existsSync(join(dir, "config/new.json"))).toBe(true);
28
+ expect(readFileSync(join(dir, "config/new.json"), "utf-8")).toBe('{"mode":"strict"}');
29
+ });
30
+
31
+ it("旧路径存在 + 新路径已存在 → 删除旧文件(新的是当前配置,旧的是残留副本)", () => {
32
+ mkdirSync(join(dir, "config"), { recursive: true });
33
+ writeFileSync(join(dir, "old.json"), "OLD", "utf-8");
34
+ writeFileSync(join(dir, "config/new.json"), "NEW", "utf-8");
35
+ const result = migrateLegacyConfig(dir, "old.json", "config/new.json");
36
+ expect(result.migrated).toBe(false);
37
+ expect(result.removedLegacy).toBe(true);
38
+ // 旧文件被删(清理残留),新文件保留不被覆盖
39
+ expect(existsSync(join(dir, "old.json"))).toBe(false);
40
+ expect(readFileSync(join(dir, "config/new.json"), "utf-8")).toBe("NEW");
41
+ });
42
+
43
+ it("迁移失败 → 不抛错,返回 error(best-effort,旧文件仍在)", () => {
44
+ // blocking-file 是文件不是目录,newRel 的父目录 mkdirSync 失败
45
+ writeFileSync(join(dir, "blocking-file"), "", "utf-8");
46
+ writeFileSync(join(dir, "old.json"), "X", "utf-8");
47
+ const result = migrateLegacyConfig(dir, "old.json", "blocking-file/new.json");
48
+ expect(result.migrated).toBe(false);
49
+ expect(result.error).toBeDefined();
50
+ expect(existsSync(join(dir, "old.json"))).toBe(true);
51
+ });
52
+ });
@@ -0,0 +1,123 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ import { resolveModel } from "../resolve.ts";
6
+
7
+ /** 构造最小 Model(cast 绕过必填字段,单测只关心 provider/id)。 */
8
+ function makeModel(provider: string, id: string): Model<Api> {
9
+ return { id, provider, name: id, api: "anthropic" as Api, baseUrl: "", reasoning: false } as unknown as Model<Api>;
10
+ }
11
+
12
+ /** 构造 mock ExtensionContext(只填 modelRegistry 的 resolveModel 依赖的方法)。 */
13
+ function makeCtx(registry: {
14
+ find?: (provider: string, modelId: string) => Model<Api> | undefined;
15
+ getAll?: () => Model<Api>[];
16
+ getAvailable?: () => Model<Api>[];
17
+ hasConfiguredAuth?: (model: Model<Api>) => boolean;
18
+ }): ExtensionContext {
19
+ return {
20
+ modelRegistry: {
21
+ find: vi.fn(registry.find ?? (() => undefined)),
22
+ getAll: vi.fn(registry.getAll ?? (() => [])),
23
+ getAvailable: vi.fn(registry.getAvailable ?? (() => [])),
24
+ hasConfiguredAuth: vi.fn(registry.hasConfiguredAuth ?? (() => false)),
25
+ getApiKeyAndHeaders: vi.fn(),
26
+ },
27
+ } as unknown as ExtensionContext;
28
+ }
29
+
30
+ describe("resolveModel", () => {
31
+ describe("ref 精确匹配", () => {
32
+ it("TC3 find 命中 + hasConfiguredAuth → 返回 model", () => {
33
+ const m = makeModel("deepseek-router", "deepseek-chat");
34
+ const ctx = makeCtx({ find: () => m, hasConfiguredAuth: () => true });
35
+ expect(resolveModel(ctx, { type: "ref", ref: "deepseek-router/deepseek-chat" })).toBe(m);
36
+ });
37
+
38
+ it("TC4 find 命中但 hasConfiguredAuth=false → null", () => {
39
+ const m = makeModel("a", "1");
40
+ const ctx = makeCtx({ find: () => m, hasConfiguredAuth: () => false });
41
+ expect(resolveModel(ctx, { type: "ref", ref: "a/1" })).toBeNull();
42
+ });
43
+
44
+ it("TC4 find 未命中(undefined)→ null(静默降级不抛错)", () => {
45
+ const ctx = makeCtx({ find: () => undefined, hasConfiguredAuth: () => true });
46
+ expect(resolveModel(ctx, { type: "ref", ref: "x/9" })).toBeNull();
47
+ });
48
+ });
49
+
50
+ describe("fallback 按序", () => {
51
+ it("TC5 遍历 refs,首个可用的返回(提前返回不遍历完)", () => {
52
+ const m1 = makeModel("a", "1");
53
+ const m2 = makeModel("b", "2");
54
+ const m3 = makeModel("c", "3");
55
+ const find = vi.fn((p: string, id: string) => {
56
+ if (p === "a" && id === "1") return m1;
57
+ if (p === "b" && id === "2") return m2;
58
+ if (p === "c" && id === "3") return m3;
59
+ return undefined;
60
+ });
61
+ const hasAuth = vi.fn((m: Model<Api>) => m === m2); // 只有 m2 有 auth
62
+ const ctx = makeCtx({ find, hasConfiguredAuth: hasAuth });
63
+
64
+ expect(resolveModel(ctx, { type: "fallback", refs: ["a/1", "b/2", "c/3"] })).toBe(m2);
65
+ expect(find).toHaveBeenCalledWith("a", "1");
66
+ expect(find).toHaveBeenCalledWith("b", "2");
67
+ // m1 无 auth 提前跳过,m2 命中后立即返回,不查 c/3
68
+ expect(find).not.toHaveBeenCalledWith("c", "3");
69
+ });
70
+
71
+ it("TC5 全部无 auth → null", () => {
72
+ const m1 = makeModel("a", "1");
73
+ const ctx = makeCtx({ find: () => m1, hasConfiguredAuth: () => false });
74
+ expect(resolveModel(ctx, { type: "fallback", refs: ["a/1", "b/2"] })).toBeNull();
75
+ });
76
+ });
77
+
78
+ describe("available", () => {
79
+ it("TC6 非空数组取首个", () => {
80
+ const mA = makeModel("a", "1");
81
+ const mB = makeModel("b", "2");
82
+ const ctx = makeCtx({ getAvailable: () => [mA, mB] });
83
+ expect(resolveModel(ctx, { type: "available" })).toBe(mA);
84
+ });
85
+
86
+ it("TC6 空数组 → null", () => {
87
+ const ctx = makeCtx({ getAvailable: () => [] });
88
+ expect(resolveModel(ctx, { type: "available" })).toBeNull();
89
+ });
90
+ });
91
+
92
+ describe("ref 非法格式(parseRef 防护)", () => {
93
+ it("C3: ref 无 '/'(如 'abc')→ null,不调 find", () => {
94
+ const find = vi.fn((_provider: string, _modelId: string): Model<Api> | undefined => undefined);
95
+ const ctx = makeCtx({ find, hasConfiguredAuth: () => true });
96
+ expect(resolveModel(ctx, { type: "ref", ref: "abc" })).toBeNull();
97
+ expect(find).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("C3: ref 以 '/' 开头(如 '/model')→ null,不调 find", () => {
101
+ const find = vi.fn((_provider: string, _modelId: string): Model<Api> | undefined => undefined);
102
+ const ctx = makeCtx({ find, hasConfiguredAuth: () => true });
103
+ expect(resolveModel(ctx, { type: "ref", ref: "/model" })).toBeNull();
104
+ expect(find).not.toHaveBeenCalled();
105
+ });
106
+
107
+ it("C3: ref 以 '/' 结尾(如 'provider/')→ null,不调 find", () => {
108
+ const find = vi.fn((_provider: string, _modelId: string): Model<Api> | undefined => undefined);
109
+ const ctx = makeCtx({ find, hasConfiguredAuth: () => true });
110
+ expect(resolveModel(ctx, { type: "ref", ref: "provider/" })).toBeNull();
111
+ expect(find).not.toHaveBeenCalled();
112
+ });
113
+ });
114
+
115
+ describe("fallback 空数组", () => {
116
+ it("C3: {type:'fallback', refs:[]} → null,不调 find", () => {
117
+ const find = vi.fn((_provider: string, _modelId: string): Model<Api> | undefined => undefined);
118
+ const ctx = makeCtx({ find, hasConfiguredAuth: () => true });
119
+ expect(resolveModel(ctx, { type: "fallback", refs: [] })).toBeNull();
120
+ expect(find).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+ });
@@ -0,0 +1,139 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+
8
+ import { matchGlob, readEnabledModels, resolveModel } from "../resolve.ts";
9
+
10
+ function makeModel(provider: string, id: string): Model<Api> {
11
+ return { id, provider, name: id, api: "anthropic" as Api, baseUrl: "", reasoning: false } as unknown as Model<Api>;
12
+ }
13
+
14
+ function makeCtx(all: Model<Api>[], hasAuth: (m: Model<Api>) => boolean): ExtensionContext {
15
+ return {
16
+ modelRegistry: {
17
+ getAll: () => all,
18
+ hasConfiguredAuth: hasAuth,
19
+ },
20
+ } as unknown as ExtensionContext;
21
+ }
22
+
23
+ let dir: string;
24
+
25
+ beforeEach(() => {
26
+ dir = mkdtempSync(join(tmpdir(), "llm-shared-scoped-"));
27
+ vi.stubEnv("PI_CODING_AGENT_DIR", dir);
28
+ });
29
+
30
+ afterEach(() => {
31
+ rmSync(dir, { recursive: true, force: true });
32
+ vi.unstubAllEnvs();
33
+ });
34
+
35
+ describe("resolveModel scoped", () => {
36
+ it("TC7 glob 匹配按 enabledModels 顺序取首个可用", () => {
37
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ enabledModels: ["anthropic/*", "openai/gpt-4o"] }));
38
+ const claude = makeModel("anthropic", "claude");
39
+ const gpt = makeModel("openai", "gpt-4o");
40
+ const gemini = makeModel("google", "gemini");
41
+ const ctx = makeCtx([claude, gpt, gemini], () => true);
42
+
43
+ // enabledModels 首个 pattern anthropic/* 命中 claude
44
+ expect(resolveModel(ctx, { type: "scoped" })).toBe(claude);
45
+ });
46
+
47
+ it("TC7 首个 pattern 无可用 model → 回退到下一个 pattern", () => {
48
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ enabledModels: ["google/*", "openai/gpt-4o"] }));
49
+ const gpt = makeModel("openai", "gpt-4o");
50
+ // getAll 不含 google,含 openai
51
+ const ctx = makeCtx([gpt], () => true);
52
+ expect(resolveModel(ctx, { type: "scoped" })).toBe(gpt);
53
+ });
54
+
55
+ it("review: scoped 同 pattern 多 model 命中序 —— 取 getAll() 遍历序首个", () => {
56
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ enabledModels: ["anthropic/*"] }));
57
+ const claude = makeModel("anthropic", "claude");
58
+ const haiku = makeModel("anthropic", "haiku");
59
+
60
+ // getAll 返回 [claude, haiku],pattern anthropic/* 都匹配,取遍历序首个 claude
61
+ const ctx1 = makeCtx([claude, haiku], () => true);
62
+ expect(resolveModel(ctx1, { type: "scoped" })).toBe(claude);
63
+
64
+ // 反序验证:取首个 haiku
65
+ const ctx2 = makeCtx([haiku, claude], () => true);
66
+ expect(resolveModel(ctx2, { type: "scoped" })).toBe(haiku);
67
+ });
68
+
69
+ it("TC8 enabledModels 缺失(无 settings.json)→ null(不抛错)", () => {
70
+ const ctx = makeCtx([makeModel("a", "1")], () => true);
71
+ expect(resolveModel(ctx, { type: "scoped" })).toBeNull();
72
+ });
73
+
74
+ it("TC8 settings.json 无 enabledModels 字段 → null", () => {
75
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ other: "field" }));
76
+ const ctx = makeCtx([makeModel("a", "1")], () => true);
77
+ expect(resolveModel(ctx, { type: "scoped" })).toBeNull();
78
+ });
79
+
80
+ it("TC8 enabledModels 空数组 → null", () => {
81
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ enabledModels: [] }));
82
+ const ctx = makeCtx([makeModel("a", "1")], () => true);
83
+ expect(resolveModel(ctx, { type: "scoped" })).toBeNull();
84
+ });
85
+
86
+ it("scoped glob 命中但全部无 auth → null", () => {
87
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ enabledModels: ["anthropic/*"] }));
88
+ const claude = makeModel("anthropic", "claude");
89
+ const ctx = makeCtx([claude], () => false);
90
+ expect(resolveModel(ctx, { type: "scoped" })).toBeNull();
91
+ });
92
+ });
93
+
94
+ describe("matchGlob", () => {
95
+ it("TC9 * 通配 / 精确 / 多段匹配", () => {
96
+ expect(matchGlob("*", "anything")).toBe(true);
97
+ expect(matchGlob("*", "a/b/c")).toBe(true);
98
+ expect(matchGlob("anthropic/*", "anthropic/claude")).toBe(true);
99
+ expect(matchGlob("anthropic/*", "openai/gpt")).toBe(false);
100
+ expect(matchGlob("openai/gpt-4o", "openai/gpt-4o")).toBe(true);
101
+ // 精确匹配不含通配,后缀不同不匹配
102
+ expect(matchGlob("openai/gpt-4o", "openai/gpt-4o-mini")).toBe(false);
103
+ expect(matchGlob("*-router/*", "deepseek-router/deepseek-chat")).toBe(true);
104
+ expect(matchGlob("*-router/*", "deepseek/deepseek-chat")).toBe(false);
105
+ });
106
+
107
+ it("TC9 特殊字符转义(pattern 含 . 等正则元字符,按字面匹配)", () => {
108
+ // gpt-4o 中的 . 若不被转义会匹配任意字符;这里无 . 但有 -,- 在字符类外非特殊
109
+ expect(matchGlob("v1.0/stable", "v1.0/stable")).toBe(true);
110
+ expect(matchGlob("v1.0/stable", "v1X0/stable")).toBe(false); // . 被转义,不匹配任意字符
111
+ });
112
+ });
113
+
114
+ describe("readEnabledModels", () => {
115
+ it("TC10 解析 + 顺序保持(非字母序原样)", () => {
116
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ enabledModels: ["b/2", "a/1", "c/3"] }));
117
+ expect(readEnabledModels()).toEqual(["b/2", "a/1", "c/3"]);
118
+ });
119
+
120
+ it("TC10 过滤非 string 元素", () => {
121
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ enabledModels: [1, "a/1", null, true, "b/2"] }));
122
+ expect(readEnabledModels()).toEqual(["a/1", "b/2"]);
123
+ });
124
+
125
+ it("TC10 坏 JSON → []", () => {
126
+ writeFileSync(join(dir, "settings.json"), "{not json");
127
+ expect(readEnabledModels()).toEqual([]);
128
+ });
129
+
130
+ it("TC10 enabledModels 非数组 → []", () => {
131
+ writeFileSync(join(dir, "settings.json"), JSON.stringify({ enabledModels: "anthropic/*" }));
132
+ expect(readEnabledModels()).toEqual([]);
133
+ });
134
+
135
+ it("TC10 顶层非对象 → []", () => {
136
+ writeFileSync(join(dir, "settings.json"), JSON.stringify(["a/1"]));
137
+ expect(readEnabledModels()).toEqual([]);
138
+ });
139
+ });
package/src/call.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * LLM 调用封装:completeSimple + 凭证注入 + 文本提取 + 错误归一化。
3
+ *
4
+ * import 方式:顶层【静态】import completeSimple。
5
+ * 探针①(2026-08-12,pi 0.84.0)实测:pi extension loader 加载含顶层
6
+ * `import { completeSimple } from "@earendil-works/pi-ai/compat"` 的 extension 不 throw,
7
+ * typeof completeSimple === "function"。compat.js 本身是真实模块(非 throwing stub),
8
+ * rename-session/llm.ts 旧注释「加载阶段 compat 是 throwing stub」已过时(其代码用 import type +
9
+ * 动态 import,从未真正测过顶层静态运行时 import)。故本库用静态 import,更简单且 tree-shake 友好。
10
+ *
11
+ * 凭证:getApiKeyAndHeaders 返回判别联合 ResolvedRequestAuth,必须 `if(!auth.ok) return` narrow
12
+ * 后才能取 apiKey/headers/env(否则 TS 报错、运行时 auth.error 不存在)。
13
+ */
14
+
15
+ // 顶层静态 import —— 探针①已验证加载阶段不 throw(见模块注释)
16
+ import { completeSimple } from "@earendil-works/pi-ai/compat";
17
+ import type { Context as LlmContext, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
18
+ import type { Api, Message, Model } from "@earendil-works/pi-ai";
19
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
20
+
21
+ // ──────────────────────── 类型 ────────────────────────
22
+
23
+ /** callLLM 入参。tools 不在此 —— callLLM 内部显式传 tools:[] 给 Context(不塞工具,best-effort 语义)。 */
24
+ export interface CallLLMOptions {
25
+ /** resolveModel 返回的 Model 对象(不是字符串)。 */
26
+ model: Model<Api>;
27
+ /** 独立 system prompt,调用方负责构造(不复用 ctx.getSystemPrompt)。 */
28
+ systemPrompt: string;
29
+ /** pi-ai Message[](对话历史 + 当前指令)。 */
30
+ messages: Message[];
31
+ maxTokens?: number;
32
+ signal?: AbortSignal;
33
+ timeoutMs?: number;
34
+ /** 透传给 SimpleStreamOptions.sessionId(provider 用于 session 缓存 / 路由)。review TF1 新增。 */
35
+ sessionId?: string;
36
+ }
37
+
38
+ /**
39
+ * callLLM 出参。
40
+ * - ok:true → content 为提取并 trim 的文本
41
+ * - ok:false → recoverable 表示可恢复性(C2b:当前实现统一 true,细分待未来有消费者);
42
+ * stopReason 是独立透传字段(失败原因维度,不映射 recoverable),供调用方保留
43
+ * error/aborted 的日志区分(如 permission classifier 的 G3 语义)。
44
+ */
45
+ export type CallLLMResult =
46
+ | { ok: true; content: string }
47
+ | { ok: false; error: string; recoverable: boolean; stopReason?: "error" | "aborted" };
48
+
49
+ // ──────────────────────── 文本提取 ────────────────────────
50
+
51
+ /**
52
+ * 从 AssistantMessage.content 提取所有 text block 拼接并 trim。
53
+ *
54
+ * 参数用结构类型(不直接依赖 AssistantMessage),便于测试 mock —— 调用方传 completeSimple 返回值即可。
55
+ * 无 text block(如纯 ThinkingContent / ToolCall)→ 返回 ""。
56
+ */
57
+ export function extractText(resp: {
58
+ content: ReadonlyArray<{ type: string; text?: string }>;
59
+ }): string {
60
+ return resp.content
61
+ .filter((block) => block.type === "text")
62
+ .map((block) => block.text ?? "")
63
+ .join(" ")
64
+ .trim();
65
+ }
66
+
67
+ // ──────────────────────── 调用 ────────────────────────
68
+
69
+ /**
70
+ * 发起一次 LLM 调用(completeSimple),返回归一化结果。
71
+ *
72
+ * 流程:
73
+ * 1. 凭证:getApiKeyAndHeaders(model) → narrow(auth.ok 判别联合)→ 返回 {ok:false} 提前返回;
74
+ * reject(抛异常,非返回 {ok:false})也落入步骤 5(B5:凭证注入与 completeSimple 同处 try,
75
+ * 保证 reject 归一为 {ok:false},调用方日志前缀一致)
76
+ * 2. 调用:completeSimple(model, {systemPrompt, messages, tools:[]}, {apiKey, headers?, env?, signal?, maxTokens?, timeoutMs?, sessionId?})
77
+ * 3. 检查 resp.stopReason:error/aborted(completeSimple 对错误/中止也 resolve 带 stopReason,G3)
78
+ * → {ok:false, error: 提取错误文本, recoverable:true, stopReason}(不再当正常内容提取)
79
+ * 4. 提取 text → {ok:true, content}
80
+ * 5. throw(getApiKeyAndHeaders reject / 网络 / 超时 / 解析)→ catch → {ok:false, error:String(e), recoverable:true}
81
+ * (C2b:catch 路径不细分 recoverable,统一 true;stopReason 不设——错误原因不可知)
82
+ *
83
+ * tools 显式传 [](不塞工具)—— 本库用于标题生成等 best-effort 场景,不需要工具调用。
84
+ */
85
+ export async function callLLM(
86
+ ctx: ExtensionContext,
87
+ opts: CallLLMOptions,
88
+ ): Promise<CallLLMResult> {
89
+ // 整个流程纳入 try:getApiKeyAndHeaders / completeSimple 任一 reject/throw 都归一为
90
+ // {ok:false, recoverable:true},保证调用方日志前缀一致(B5:凭证注入原在 try 外,reject 时
91
+ // callLLM 直接 reject,上游走外层 .catch 输出不一致前缀,如 [pi-rename-session] 而非 [rename-session])。
92
+ try {
93
+ // 1. 凭证(判别联合必须 narrow):返回 {ok:false} → 提前返回;reject(抛异常)→ 进 catch
94
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(opts.model);
95
+ if (!auth.ok) {
96
+ return { ok: false, error: auth.error, recoverable: true };
97
+ }
98
+
99
+ // 2. 调用 completeSimple(字段名经探针⑤对齐:Context{systemPrompt?,messages,tools?},
100
+ // SimpleStreamOptions extends StreamOptions{apiKey?,headers?,env?,signal?,maxTokens?,timeoutMs?,sessionId?})
101
+ const context: LlmContext = {
102
+ systemPrompt: opts.systemPrompt,
103
+ messages: opts.messages,
104
+ tools: [],
105
+ };
106
+ // apiKey/headers/env 来自 auth(即使 undefined 也传,让 completeSimple 用默认);
107
+ // signal/maxTokens/timeoutMs/sessionId 条件 spread(不设置则不传,保留 completeSimple 默认)。
108
+ const options: SimpleStreamOptions = {
109
+ apiKey: auth.apiKey,
110
+ headers: auth.headers,
111
+ env: auth.env,
112
+ ...(opts.signal ? { signal: opts.signal } : {}),
113
+ ...(opts.maxTokens ? { maxTokens: opts.maxTokens } : {}),
114
+ ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}),
115
+ ...(opts.sessionId ? { sessionId: opts.sessionId } : {}),
116
+ };
117
+ const resp = await completeSimple(opts.model, context, options);
118
+ // G3/C1a:completeSimple 对 error/aborted 也 resolve(带 stopReason,不 reject)。
119
+ // 归一为 ok:false + stopReason 独立透传(recoverable 统一 true,与 C2b 一致不触发细分)。
120
+ if (resp.stopReason === "error" || resp.stopReason === "aborted") {
121
+ const errorText = extractText(resp) || "unknown error";
122
+ return { ok: false, error: errorText, recoverable: true, stopReason: resp.stopReason };
123
+ }
124
+ return { ok: true, content: extractText(resp) };
125
+ } catch (error) {
126
+ return { ok: false, error: error instanceof Error ? error.message : String(error), recoverable: true };
127
+ }
128
+ }
package/src/config.ts ADDED
@@ -0,0 +1,180 @@
1
+ /**
2
+ * 泛型配置读写:getConfigPath + loadConfig<T> + saveConfig + mtime+size 缓存 + 原子写。
3
+ *
4
+ * 与 permission/config.ts 的区别:本库是泛型版(pkgName 参数化,normalize 由调用方传),
5
+ * 不内置任何 schema —— rename-session / permission / scheduler 等 consumer 各自定义 normalize。
6
+ * 范式(mtime+size 双 key 缓存、原子写 tmp+rename、tmp 失败清理)借鉴 permission/config.ts。
7
+ *
8
+ * 路径解析用 pi 导出的 getAgentDir(尊重 PI_CODING_AGENT_DIR 覆盖),禁止自实现 ——
9
+ * permission/config.ts:18-22 有重复自实现待 P3 清理,本库直接用 pi 导出版。
10
+ *
11
+ * ── 热重载契约(consumer 必读) ──
12
+ * 本库的 loadConfig 提供「读时刷新(pull-based)热重载」:每次调用 statSync 文件 mtime+size,
13
+ * 变了才重读+重新 normalize,没变返回深拷贝(成本≈一次 metadata stat,不读文件内容)。
14
+ * 这是框架对 consumer 统一提供的热重载能力——consumer 应在【每次需要配置时直接调 loadConfig】,
15
+ * 禁止在上层套手动缓存/闭包缓存(如 `let config = loadXxx()` + 手动 refresh 调用点),否则阻断
16
+ * 读时刷新,导致「同进程内改文件不生效」。
17
+ * 历史教训:permission 曾用闭包缓存架空了 loadConfig 的读时刷新——tool_call handler 拿闭包
18
+ * 里的旧 config,传了 refresh 参数却未调用(_refreshConfig 下划线=未用),同一 session 改配置
19
+ * 文件后下次工具调用仍用旧 config。正确范式见 rename-session(每次 turn_end 直接 load)。
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
23
+ import { dirname, join } from "node:path";
24
+
25
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
26
+
27
+ /** JSON 序列化缩进格数(permission/config.ts 同款)。 */
28
+ const JSON_INDENT = 2;
29
+
30
+ // ──────────────────────── 路径 ────────────────────────
31
+
32
+ /** 配置文件完整路径:<agentDir>/config/<pkgName>-ext-config.json(与 config skill 名 `<简名>-ext-config` 统一)。 */
33
+ export function getConfigPath(pkgName: string): string {
34
+ return join(getAgentDir(), "config", `${pkgName}-ext-config.json`);
35
+ }
36
+
37
+ // ──────────────────────── mtime+size 缓存 ────────────────────────
38
+
39
+ interface CacheEntry<T> {
40
+ mtimeMs: number;
41
+ size: number;
42
+ config: T;
43
+ }
44
+
45
+ /**
46
+ * 模块级缓存:path → {mtimeMs, size, config}。单进程多 session 共享读缓存安全(配置只读)。
47
+ * mtime + size 双 key:防 APFS 等文件系统 mtime 精度截断导致快速连续保存后缓存失效。
48
+ * 已知 limitation:「同毫秒同字节大小但内容不同」的写入会误命中(概率极低,权衡采用 mtime+size)。
49
+ */
50
+ const configCache = new Map<string, CacheEntry<unknown>>();
51
+
52
+ /** 测试用:清空缓存。 */
53
+ export function clearConfigCache(): void {
54
+ configCache.clear();
55
+ }
56
+
57
+ /** 深拷贝(防调用方修改返回值污染缓存)。Node 22+ 内置 structuredClone。 */
58
+ function clone<T>(value: T): T {
59
+ return typeof structuredClone === "function"
60
+ ? structuredClone(value)
61
+ : (JSON.parse(JSON.stringify(value)) as T);
62
+ }
63
+
64
+ // ──────────────────────── 加载(带缓存) ────────────────────────
65
+
66
+ /**
67
+ * 加载配置,文件未变化时返回缓存(深拷贝,防调用方修改污染缓存)。
68
+ *
69
+ * @param pkgName 包名(决定文件路径 <agentDir>/config/<pkgName>.json)
70
+ * @param defaults 文件缺失/坏 JSON/normalize 失败时的默认值
71
+ * @param normalize 把 JSON.parse 的 unknown 归一化成 T(调用方负责校验 + 默认值填充)
72
+ * @param onWarning 非致命问题(解析失败)的警告回调
73
+ *
74
+ * 降级:文件不存在 → defaults;坏 JSON / normalize throw → defaults(onWarning 回调)。
75
+ * 坏文件也更新缓存 mtime+size(缓存 defaults),避免每次重读损坏文件;mtime 变化时缓存自动失效。
76
+ *
77
+ * 【热重载契约】本函数自带读时刷新:文件 mtime/size 变化时自动重读。consumer 每次需要配置直接
78
+ * 调用本函数即可(文件未变时零额外 IO——只 statSync 不读内容),禁止在上层套闭包/手动缓存阻断
79
+ * 刷新。详见文件头「热重载契约」段。
80
+ */
81
+ export function loadConfig<T>(
82
+ pkgName: string,
83
+ defaults: T,
84
+ normalize: (raw: unknown) => T,
85
+ onWarning?: (msg: string) => void,
86
+ ): T {
87
+ const configPath = getConfigPath(pkgName);
88
+
89
+ let stat;
90
+ try {
91
+ stat = statSync(configPath);
92
+ } catch {
93
+ // 文件不存在 / 不可 stat → defaults(不缓存,下次仍尝试读,文件创建后自动生效)
94
+ return clone(defaults);
95
+ }
96
+
97
+ const cached = configCache.get(configPath) as CacheEntry<T> | undefined;
98
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
99
+ return clone(cached.config);
100
+ }
101
+
102
+ try {
103
+ const raw = readFileSync(configPath, "utf-8");
104
+ const parsed: unknown = JSON.parse(raw);
105
+ const config = normalize(parsed);
106
+ configCache.set(configPath, { mtimeMs: stat.mtimeMs, size: stat.size, config });
107
+ return clone(config);
108
+ } catch (error) {
109
+ const message = error instanceof Error ? error.message : String(error);
110
+ onWarning?.(`[llm-shared] Config parse failed at '${configPath}', using default: ${message}`);
111
+ // 缓存 defaults + 当前 mtime/size,避免每次重读损坏文件(mtime 变化时缓存自动失效)
112
+ configCache.set(configPath, { mtimeMs: stat.mtimeMs, size: stat.size, config: clone(defaults) });
113
+ return clone(defaults);
114
+ }
115
+ }
116
+
117
+ // ──────────────────────── 保存(原子写) ────────────────────────
118
+
119
+ /**
120
+ * 保存配置(原子写:tmp 文件 + rename)。
121
+ *
122
+ * @returns 成功 {success:true};失败 {success:false, error}
123
+ *
124
+ * 原子性:writeFileSync(tmp) + renameSync(tmp→target),rename 是原子的(POSIX/Windows)。
125
+ * tmp 失败清理(review RK3):writeFileSync 或 renameSync 抛错时,catch 块 unlinkSync(tmp)
126
+ * 清理残留 tmp 文件(unlink 本身 try/catch,避免二次抛错)。
127
+ * 写后立即 statSync 更新缓存(覆盖最常见的「写后读」竞态)。
128
+ *
129
+ * Windows 行为说明(探针 4):renameSync 在目标文件被占用(打开句柄未关闭)时抛 EPERM,
130
+ * 无 fallback —— catch 路径返回 {success:false} + onWarning + tmp 清理,调用方(配置写入方)
131
+ * 应视保存失败处理(如保留内存态、下次触发重写)。非致命:配置写入失败不影响运行,
132
+ * 下次 save 仍会重试。单测见 __tests__/config.test.ts 的 ENOENT/EPERM 用例。
133
+ */
134
+ export function saveConfig(
135
+ pkgName: string,
136
+ config: unknown,
137
+ onWarning?: (msg: string) => void,
138
+ ): { success: boolean; error?: string } {
139
+ const configPath = getConfigPath(pkgName);
140
+ const tmpPath = `${configPath}.tmp`;
141
+ const content = `${JSON.stringify(config, null, JSON_INDENT)}\n`;
142
+
143
+ try {
144
+ mkdirSync(dirname(configPath), { recursive: true });
145
+ writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
146
+ renameSync(tmpPath, configPath);
147
+
148
+ // 写后更新缓存(用新文件 mtime+size + 写入的 config)
149
+ try {
150
+ const newStat = statSync(configPath);
151
+ configCache.set(configPath, {
152
+ mtimeMs: newStat.mtimeMs,
153
+ size: newStat.size,
154
+ config: clone(config),
155
+ });
156
+ } catch (statErr) {
157
+ // stat 失败不影响保存成功;缓存下次 load 时会重读
158
+ console.warn(
159
+ `[llm-shared] saveConfig stat after write failed:`,
160
+ statErr instanceof Error ? statErr.message : String(statErr),
161
+ );
162
+ }
163
+
164
+ return { success: true };
165
+ } catch (error) {
166
+ // RK3: 清理残留 tmp 文件(writeFileSync 或 renameSync 失败时 tmp 可能残留)
167
+ try {
168
+ if (existsSync(tmpPath)) unlinkSync(tmpPath);
169
+ } catch (cleanupErr) {
170
+ // tmp 清理失败不能阻塞保存失败的返回;记录原因
171
+ console.warn(
172
+ `[llm-shared] saveConfig tmp cleanup failed:`,
173
+ cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
174
+ );
175
+ }
176
+ const message = error instanceof Error ? error.message : String(error);
177
+ onWarning?.(`[llm-shared] Failed to save config at '${configPath}': ${message}`);
178
+ return { success: false, error: `Failed to save config at '${configPath}': ${message}` };
179
+ }
180
+ }
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ // @zhushanwen/pi-llm-shared —— 统一 public API 出口。
2
+ // resolve: 模型解析(四形式 selector)
3
+ // call: LLM 调用(completeSimple + 凭证 + 文本提取)
4
+ // config: 泛型配置读写(mtime 缓存 + 原子写)
5
+ export { resolveModel, readEnabledModels, matchGlob, type ModelSelector } from "./resolve.ts";
6
+ export { callLLM, extractText, type CallLLMOptions, type CallLLMResult } from "./call.ts";
7
+ export { getConfigPath, loadConfig, saveConfig, clearConfigCache } from "./config.ts";
8
+ export { migrateLegacyConfig, type MigrationResult } from "./migrate.ts";
package/src/migrate.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * 历史配置路径迁移工具(幂等,过渡性——供 extension session_start hook 调用)。
3
+ *
4
+ * 迁移语义:
5
+ * - 旧路径不存在 → noop
6
+ * - 旧路径存在 + 新路径不存在 → renameSync 原子搬移
7
+ * - 旧路径存在 + 新路径已存在 → 删除旧文件(新的是当前配置,旧的是残留副本;pi 运行时只读新路径)
8
+ * - 失败 → warn 不抛错(best-effort,下次启动重试)
9
+ *
10
+ * 调用方在 session_start hook 里用模块级 once flag 防同进程重复触发。
11
+ * agentDir 由调用方传入(通常 getAgentDir()),便于测试传 tmp dir。
12
+ *
13
+ * 设计依据见 docs/extensions/extension-conventions.md §配置路径约定「历史路径迁移」。
14
+ */
15
+ import { existsSync, mkdirSync, renameSync, unlinkSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+
18
+ export interface MigrationResult {
19
+ migrated: boolean;
20
+ /** 新路径已存在时已删除旧文件(清理残留副本),true 表示旧文件被删。 */
21
+ removedLegacy?: boolean;
22
+ /** 迁移失败时的错误对象(best-effort,不抛错)。 */
23
+ error?: unknown;
24
+ }
25
+
26
+ /**
27
+ * 迁移单个配置文件:`<agentDir>/<oldRel>` → `<agentDir>/<newRel>`。
28
+ * 幂等、best-effort,重复调用安全。
29
+ */
30
+ export function migrateLegacyConfig(agentDir: string, oldRel: string, newRel: string): MigrationResult {
31
+ const oldPath = join(agentDir, oldRel);
32
+ if (!existsSync(oldPath)) return { migrated: false };
33
+
34
+ const newPath = join(agentDir, newRel);
35
+ try {
36
+ mkdirSync(dirname(newPath), { recursive: true });
37
+ if (existsSync(newPath)) {
38
+ // 新已存在 = 已迁移过(或用户用新路径),旧的是残留副本——删除清理。
39
+ // pi 运行时只读新路径,旧文件无用。安全前提:session_start 迁移在 pi 进程内、
40
+ // 用户主动启动时触发(非 postinstall 开发环境误触发;e112a14fc 场景随 session_start 消除)。
41
+ unlinkSync(oldPath);
42
+ console.warn(`[migrate-config] new config already exists, removed legacy file: ${oldPath}`);
43
+ return { migrated: false, removedLegacy: true };
44
+ }
45
+ renameSync(oldPath, newPath);
46
+ console.warn(`[migrate-config] migrated: ${oldPath} -> ${newPath}`);
47
+ return { migrated: true };
48
+ } catch (e) {
49
+ console.warn(`[migrate-config] migration failed for ${oldPath} -> ${newPath}:`, e);
50
+ return { migrated: false, error: e };
51
+ }
52
+ }
package/src/resolve.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * 模型解析:把 ModelSelector(四形式)解析成可用的 Model,或 null(不可用,调用方静默跳过)。
3
+ *
4
+ * 设计依据:design.md §3.4 + slice 6 决策。scoped 形式自读 <agentDir>/settings.json 的
5
+ * enabledModels(string[],"provider/modelId" 格式可含 * glob),不依赖调用方传入列表 ——
6
+ * 这样 rename-session / permission 等 consumer 无需各自重复读 settings.json 的逻辑。
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import type { Api, Model } from "@earendil-works/pi-ai";
13
+ import { getAgentDir, type ExtensionContext } from "@earendil-works/pi-coding-agent";
14
+
15
+ // ──────────────────────── 类型 ────────────────────────
16
+
17
+ /**
18
+ * 模型选择器(四形式)。
19
+ * - ref: "provider/modelId" 精确,需 hasConfiguredAuth
20
+ * - fallback: 按序尝试 refs,首个可用的返回
21
+ * - available: getAvailable()[0](pi 已配置 auth 的全量模型池)
22
+ * - scoped: 读 settings.json enabledModels glob 匹配 getAll(),按用户排序取首个可用
23
+ */
24
+ export type ModelSelector =
25
+ | { type: "ref"; ref: string }
26
+ | { type: "fallback"; refs: string[] }
27
+ | { type: "available" }
28
+ | { type: "scoped" };
29
+
30
+ // ──────────────────────── glob 匹配 ────────────────────────
31
+
32
+ /**
33
+ * 自实现 * 通配匹配(不引入 minimatch 依赖)。
34
+ *
35
+ * 只支持 `*`(匹配任意字符序列),不支持 `?` / `**` / 字符类 —— enabledModels 的 pattern
36
+ * 只需 "provider/*" 这种简单通配。实现:把 pattern 转成正则,特殊字符转义(`*` 单独转成 `.*`),
37
+ * 全程 `^...$` 锚定。
38
+ *
39
+ * 例:`*` 匹配任意;`anthropic/*` 匹配 `anthropic/claude`;`openai/gpt-4o` 精确匹配。
40
+ */
41
+ export function matchGlob(pattern: string, str: string): boolean {
42
+ const re = pattern.replace(/[\\^$.|?*+(){}[\]]/g, (ch) => (ch === "*" ? ".*" : `\\${ch}`));
43
+ return new RegExp(`^${re}$`).test(str);
44
+ }
45
+
46
+ // ──────────────────────── settings.json 读取 ────────────────────────
47
+
48
+ /**
49
+ * 读取 <agentDir>/settings.json 的 enabledModels 字段(string[])。
50
+ *
51
+ * 降级策略(scoped 形式据此返回 null,绝不抛错):
52
+ * - 文件不存在 / 读失败 → []
53
+ * - 坏 JSON / 顶层非对象 → []
54
+ * - enabledModels 缺失 / 非数组 → []
55
+ * - 非 string 元素过滤掉,保持剩余元素顺序
56
+ *
57
+ * 用 pi 导出的 getAgentDir(尊重 PI_CODING_AGENT_DIR 覆盖)。
58
+ */
59
+ export function readEnabledModels(): string[] {
60
+ const filePath = join(getAgentDir(), "settings.json");
61
+
62
+ let raw: string;
63
+ try {
64
+ raw = readFileSync(filePath, "utf-8");
65
+ } catch {
66
+ return [];
67
+ }
68
+
69
+ let parsed: unknown;
70
+ try {
71
+ parsed = JSON.parse(raw);
72
+ } catch {
73
+ return [];
74
+ }
75
+
76
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return [];
77
+ const enabled = (parsed as Record<string, unknown>).enabledModels;
78
+ if (!Array.isArray(enabled)) return [];
79
+ return enabled.filter((x): x is string => typeof x === "string");
80
+ }
81
+
82
+ // ──────────────────────── 模型解析 ────────────────────────
83
+
84
+ /** "provider/modelId" → 拆分(用 indexOf 而非 split,modelId 理论上可含 /,取首个 / 作分隔)。 */
85
+ function parseRef(ref: string): { provider: string; modelId: string } | null {
86
+ const idx = ref.indexOf("/");
87
+ if (idx <= 0 || idx >= ref.length - 1) return null; // 缺 / 或前后为空
88
+ return { provider: ref.slice(0, idx), modelId: ref.slice(idx + 1) };
89
+ }
90
+
91
+ /** ref 精确匹配:find 命中 + hasConfiguredAuth。任一失败返回 null(静默降级)。 */
92
+ function resolveRef(ctx: ExtensionContext, ref: string): Model<Api> | null {
93
+ const parsed = parseRef(ref);
94
+ if (!parsed) return null;
95
+ const model = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
96
+ if (!model) return null;
97
+ if (!ctx.modelRegistry.hasConfiguredAuth(model)) return null;
98
+ return model;
99
+ }
100
+
101
+ /** fallback:按序尝试 refs,首个可用的返回(提前返回,不遍历完)。 */
102
+ function resolveFallback(ctx: ExtensionContext, refs: string[]): Model<Api> | null {
103
+ for (const ref of refs) {
104
+ const model = resolveRef(ctx, ref);
105
+ if (model) return model;
106
+ }
107
+ return null;
108
+ }
109
+
110
+ /** available:getAvailable()[0](pi 已配置 auth 的模型池,取首个)。空池返回 null。 */
111
+ function resolveAvailable(ctx: ExtensionContext): Model<Api> | null {
112
+ const list = ctx.modelRegistry.getAvailable();
113
+ return list.length > 0 ? list[0] : null;
114
+ }
115
+
116
+ /**
117
+ * scoped:读 settings.json enabledModels,按用户排序遍历 pattern,
118
+ * 每个 pattern 对 getAll() 的 `${provider}/${id}` 做 matchGlob,首个 hasConfiguredAuth 命中即返回。
119
+ *
120
+ * 命中序:外层按 enabledModels 顺序(用户排序优先级),内层按 getAll() 返回顺序(pi 注册序)。
121
+ * 即 enabledModels 首个 pattern 的首个可用匹配优先 —— 符合「用户排序首位」语义。
122
+ */
123
+ function resolveScoped(ctx: ExtensionContext): Model<Api> | null {
124
+ const patterns = readEnabledModels();
125
+ if (patterns.length === 0) return null;
126
+ const all = ctx.modelRegistry.getAll();
127
+ for (const pattern of patterns) {
128
+ for (const model of all) {
129
+ if (matchGlob(pattern, `${model.provider}/${model.id}`) && ctx.modelRegistry.hasConfiguredAuth(model)) {
130
+ return model;
131
+ }
132
+ }
133
+ }
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * 按 selector 形式解析模型。返回 null = 不可用,调用方静默跳过(不抛错)。
139
+ *
140
+ * 走 ctx.modelRegistry(pi 三源合并后的模型注册表)。hasConfiguredAuth 过滤掉未配置凭证的模型。
141
+ */
142
+ export function resolveModel(ctx: ExtensionContext, selector: ModelSelector): Model<Api> | null {
143
+ if (selector.type === "ref") return resolveRef(ctx, selector.ref);
144
+ if (selector.type === "fallback") return resolveFallback(ctx, selector.refs);
145
+ if (selector.type === "available") return resolveAvailable(ctx);
146
+ return resolveScoped(ctx); // selector.type === "scoped"
147
+ }