@zhushanwen/pi-llm-shared 0.2.0 → 0.3.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.
- package/package.json +6 -2
- package/src/__tests__/call.test.ts +41 -0
- package/src/__tests__/config.test.ts +38 -8
- package/src/__tests__/resolve.test.ts +28 -87
- package/src/call.ts +12 -2
- package/src/config.ts +70 -36
- package/src/index.ts +2 -2
- package/src/resolve.ts +9 -110
- package/src/__tests__/scoped.test.ts +0 -139
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-llm-shared",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shared LLM invocation library for Pi extensions — model resolution (ref
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Shared LLM invocation library for Pi extensions — model resolution (ref exact only), LLM calling (completeSimple), and config read/write with mtime caching. Shared library, not a Pi extension.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
7
7
|
"keywords": [
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
"src/",
|
|
18
18
|
"index.ts"
|
|
19
19
|
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@zhushanwen/pi-file-lock": "0.1.1"
|
|
22
|
+
},
|
|
20
23
|
"peerDependencies": {
|
|
21
24
|
"@earendil-works/pi-ai": "*",
|
|
22
25
|
"@earendil-works/pi-coding-agent": "*"
|
|
@@ -32,6 +35,7 @@
|
|
|
32
35
|
"devDependencies": {
|
|
33
36
|
"@earendil-works/pi-ai": "*",
|
|
34
37
|
"@earendil-works/pi-coding-agent": "*",
|
|
38
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
35
39
|
"vitest": "^4.1.8"
|
|
36
40
|
},
|
|
37
41
|
"scripts": {
|
|
@@ -135,6 +135,47 @@ describe("callLLM", () => {
|
|
|
135
135
|
expect("sessionId" in optionsArg).toBe(false);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
+
it("reasoning 透传:传 reasoning=high → options 含 reasoning:high", async () => {
|
|
139
|
+
const ctx = makeCtx({ ok: true, apiKey: "k" });
|
|
140
|
+
mockComplete.mockResolvedValue({ content: [{ type: "text", text: "x" }] });
|
|
141
|
+
|
|
142
|
+
await callLLM(ctx, {
|
|
143
|
+
model: makeModel(),
|
|
144
|
+
systemPrompt: "s",
|
|
145
|
+
messages: [],
|
|
146
|
+
reasoning: "high",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const optionsArg = mockComplete.mock.calls[0][2];
|
|
150
|
+
expect(optionsArg).toMatchObject({ reasoning: "high" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("reasoning 不传 → options 不含 reasoning 字段(条件 spread,provider 默认)", async () => {
|
|
154
|
+
const ctx = makeCtx({ ok: true, apiKey: "k" });
|
|
155
|
+
mockComplete.mockResolvedValue({ content: [{ type: "text", text: "x" }] });
|
|
156
|
+
|
|
157
|
+
await callLLM(ctx, { model: makeModel(), systemPrompt: "s", messages: [] });
|
|
158
|
+
|
|
159
|
+
const optionsArg = mockComplete.mock.calls[0][2] as Record<string, unknown>;
|
|
160
|
+
expect("reasoning" in optionsArg).toBe(false);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("reasoning=off → options 不含 reasoning 字段(off 由本库映射为不传)", async () => {
|
|
164
|
+
const ctx = makeCtx({ ok: true, apiKey: "k" });
|
|
165
|
+
mockComplete.mockResolvedValue({ content: [{ type: "text", text: "x" }] });
|
|
166
|
+
|
|
167
|
+
await callLLM(ctx, {
|
|
168
|
+
model: makeModel(),
|
|
169
|
+
systemPrompt: "s",
|
|
170
|
+
messages: [],
|
|
171
|
+
reasoning: "off",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const optionsArg = mockComplete.mock.calls[0][2] as Record<string, unknown>;
|
|
175
|
+
expect("reasoning" in optionsArg).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
|
|
138
179
|
it("B5: getApiKeyAndHeaders reject(抛异常)→ {ok:false, recoverable:true}(归一入 catch,不向上抛)", async () => {
|
|
139
180
|
const getApiKeyAndHeaders = vi.fn().mockRejectedValueOnce(new Error("registry exploded"));
|
|
140
181
|
const ctx = { modelRegistry: { getApiKeyAndHeaders } } as unknown as ExtensionContext;
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
6
|
|
|
7
7
|
import { clearConfigCache, getConfigPath, loadConfig, saveConfig } from "../config.ts";
|
|
8
|
+
import * as fileLock from "@zhushanwen/pi-file-lock";
|
|
8
9
|
|
|
9
10
|
// node:fs 的 ESM namespace 不可配置,vi.spyOn 对具名导出失效(vitest 限制)。
|
|
10
11
|
// 用 vi.mock 包装 readFileSync/renameSync(默认走 actual,个别 test override),
|
|
@@ -128,6 +129,12 @@ describe("loadConfig", () => {
|
|
|
128
129
|
});
|
|
129
130
|
|
|
130
131
|
describe("saveConfig", () => {
|
|
132
|
+
/** tmp 残留断言(D1e 唯一化后 tmp 名为 <path>.tmp_<pid>_<rand>,用前缀 glob 断言)。 */
|
|
133
|
+
function tmpResidues(pkg: string): string[] {
|
|
134
|
+
const cfgDir = join(dir, "config");
|
|
135
|
+
return readdirSync(cfgDir).filter((f) => f.startsWith(`${pkg}-ext-config.json.tmp`));
|
|
136
|
+
}
|
|
137
|
+
|
|
131
138
|
it("TC16 原子写:文件落盘 + 内容正确 + 无 tmp 残留", () => {
|
|
132
139
|
const result = saveConfig("test", { b: 2 });
|
|
133
140
|
expect(result.success).toBe(true);
|
|
@@ -135,7 +142,7 @@ describe("saveConfig", () => {
|
|
|
135
142
|
const cfgPath = join(dir, "config", "test-ext-config.json");
|
|
136
143
|
expect(existsSync(cfgPath)).toBe(true);
|
|
137
144
|
expect(JSON.parse(readFileSync(cfgPath, "utf-8"))).toEqual({ b: 2 });
|
|
138
|
-
expect(
|
|
145
|
+
expect(tmpResidues("test")).toEqual([]); // 无 tmp 残留(唯一化 tmp 名,前缀断言)
|
|
139
146
|
});
|
|
140
147
|
|
|
141
148
|
it("TC16 文件 mode 0o600", () => {
|
|
@@ -164,8 +171,8 @@ describe("saveConfig", () => {
|
|
|
164
171
|
|
|
165
172
|
expect(result.success).toBe(false);
|
|
166
173
|
expect(result.error).toContain("EPERM");
|
|
167
|
-
// tmp 文件被 catch 块的 unlinkSync
|
|
168
|
-
expect(
|
|
174
|
+
// tmp 文件被 catch 块的 unlinkSync 清理(唯一化 tmp 名,前缀断言)
|
|
175
|
+
expect(tmpResidues("fail")).toEqual([]);
|
|
169
176
|
// 目标文件未被创建(rename 失败)
|
|
170
177
|
expect(existsSync(join(dir, "config", "fail-ext-config.json"))).toBe(false);
|
|
171
178
|
});
|
|
@@ -185,9 +192,10 @@ describe("saveConfig", () => {
|
|
|
185
192
|
expect(onWarning).toHaveBeenCalledTimes(1);
|
|
186
193
|
const warning = String(onWarning.mock.calls[0][0]);
|
|
187
194
|
expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "enoent-ext-config.json") + "'");
|
|
195
|
+
expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "enoent-ext-config.json") + "'");
|
|
188
196
|
expect(warning).toContain("ENOENT");
|
|
189
|
-
// tmp
|
|
190
|
-
expect(
|
|
197
|
+
// tmp 清理(前缀断言)+ 目标未创建
|
|
198
|
+
expect(tmpResidues("enoent")).toEqual([]);
|
|
191
199
|
expect(existsSync(join(dir, "config", "enoent-ext-config.json"))).toBe(false);
|
|
192
200
|
});
|
|
193
201
|
|
|
@@ -204,8 +212,8 @@ describe("saveConfig", () => {
|
|
|
204
212
|
const warning = String(onWarning.mock.calls[0][0]);
|
|
205
213
|
expect(warning).toContain("[llm-shared] Failed to save config at '" + join(dir, "config", "eperm-ext-config.json") + "'");
|
|
206
214
|
expect(warning).toContain("EPERM");
|
|
207
|
-
// tmp 清理(Windows 目标占用场景 rename 失败后 tmp
|
|
208
|
-
expect(
|
|
215
|
+
// tmp 清理(Windows 目标占用场景 rename 失败后 tmp 残留被清理;前缀断言)
|
|
216
|
+
expect(tmpResidues("eperm")).toEqual([]);
|
|
209
217
|
expect(existsSync(join(dir, "config", "eperm-ext-config.json"))).toBe(false);
|
|
210
218
|
});
|
|
211
219
|
|
|
@@ -214,4 +222,26 @@ describe("saveConfig", () => {
|
|
|
214
222
|
expect(saveConfig("test", { v: 2 }).success).toBe(true);
|
|
215
223
|
expect(JSON.parse(readFileSync(join(dir, "config", "test-ext-config.json"), "utf-8"))).toEqual({ v: 2 });
|
|
216
224
|
});
|
|
225
|
+
|
|
226
|
+
it("W4 锁不可用(ELOCKED 预算耗尽)→ {success:false} + 不降级无锁写(目标文件不落盘)", () => {
|
|
227
|
+
// 模拟 runtime 对端长期持锁:withFileLockSync 抛 ELOCKED。扩展侧契约 =
|
|
228
|
+
// 不降级无锁写(降级会与 runtime 持锁写交错丢字段),按保存失败返回。
|
|
229
|
+
const lockErr = Object.assign(new Error("[file-lock] lock unavailable: ELOCKED"), { code: "ELOCKED" });
|
|
230
|
+
const spy = vi.spyOn(fileLock, "withFileLockSync").mockImplementation(() => {
|
|
231
|
+
throw lockErr;
|
|
232
|
+
});
|
|
233
|
+
const onWarning = vi.fn();
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const result = saveConfig("lockbusy", { x: 1 }, onWarning);
|
|
237
|
+
|
|
238
|
+
expect(result.success).toBe(false);
|
|
239
|
+
expect(result.error).toContain("lock unavailable");
|
|
240
|
+
expect(onWarning).toHaveBeenCalledTimes(1);
|
|
241
|
+
// 关键:未降级写盘(无锁写会破坏与 runtime 的互斥)
|
|
242
|
+
expect(existsSync(join(dir, "config", "lockbusy-ext-config.json"))).toBe(false);
|
|
243
|
+
} finally {
|
|
244
|
+
spy.mockRestore();
|
|
245
|
+
}
|
|
246
|
+
});
|
|
217
247
|
});
|
|
@@ -12,112 +12,53 @@ function makeModel(provider: string, id: string): Model<Api> {
|
|
|
12
12
|
/** 构造 mock ExtensionContext(只填 modelRegistry 的 resolveModel 依赖的方法)。 */
|
|
13
13
|
function makeCtx(registry: {
|
|
14
14
|
find?: (provider: string, modelId: string) => Model<Api> | undefined;
|
|
15
|
-
getAll?: () => Model<Api>[];
|
|
16
|
-
getAvailable?: () => Model<Api>[];
|
|
17
15
|
hasConfiguredAuth?: (model: Model<Api>) => boolean;
|
|
18
16
|
}): ExtensionContext {
|
|
19
17
|
return {
|
|
20
18
|
modelRegistry: {
|
|
21
19
|
find: vi.fn(registry.find ?? (() => undefined)),
|
|
22
|
-
getAll: vi.fn(registry.getAll ?? (() => [])),
|
|
23
|
-
getAvailable: vi.fn(registry.getAvailable ?? (() => [])),
|
|
24
20
|
hasConfiguredAuth: vi.fn(registry.hasConfiguredAuth ?? (() => false)),
|
|
25
21
|
getApiKeyAndHeaders: vi.fn(),
|
|
26
22
|
},
|
|
27
23
|
} as unknown as ExtensionContext;
|
|
28
24
|
}
|
|
29
25
|
|
|
30
|
-
describe("resolveModel", () => {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
});
|
|
26
|
+
describe("resolveModel(仅 ref 精确指定)", () => {
|
|
27
|
+
it("find 命中 + hasConfiguredAuth → 返回 model", () => {
|
|
28
|
+
const m = makeModel("deepseek-router", "deepseek-chat");
|
|
29
|
+
const ctx = makeCtx({ find: () => m, hasConfiguredAuth: () => true });
|
|
30
|
+
expect(resolveModel(ctx, { type: "ref", ref: "deepseek-router/deepseek-chat" })).toBe(m);
|
|
48
31
|
});
|
|
49
32
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
});
|
|
33
|
+
it("find 命中但 hasConfiguredAuth=false → null", () => {
|
|
34
|
+
const m = makeModel("a", "1");
|
|
35
|
+
const ctx = makeCtx({ find: () => m, hasConfiguredAuth: () => false });
|
|
36
|
+
expect(resolveModel(ctx, { type: "ref", ref: "a/1" })).toBeNull();
|
|
76
37
|
});
|
|
77
38
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
});
|
|
39
|
+
it("find 未命中(undefined)→ null(静默降级不抛错)", () => {
|
|
40
|
+
const ctx = makeCtx({ find: () => undefined, hasConfiguredAuth: () => true });
|
|
41
|
+
expect(resolveModel(ctx, { type: "ref", ref: "x/9" })).toBeNull();
|
|
90
42
|
});
|
|
91
43
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
});
|
|
44
|
+
it("ref 无 '/'(如 'abc')→ null,不调 find", () => {
|
|
45
|
+
const find = vi.fn((_provider: string, _modelId: string): Model<Api> | undefined => undefined);
|
|
46
|
+
const ctx = makeCtx({ find, hasConfiguredAuth: () => true });
|
|
47
|
+
expect(resolveModel(ctx, { type: "ref", ref: "abc" })).toBeNull();
|
|
48
|
+
expect(find).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
106
50
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
});
|
|
51
|
+
it("ref 以 '/' 开头(如 '/model')→ null,不调 find", () => {
|
|
52
|
+
const find = vi.fn((_provider: string, _modelId: string): Model<Api> | undefined => undefined);
|
|
53
|
+
const ctx = makeCtx({ find, hasConfiguredAuth: () => true });
|
|
54
|
+
expect(resolveModel(ctx, { type: "ref", ref: "/model" })).toBeNull();
|
|
55
|
+
expect(find).not.toHaveBeenCalled();
|
|
113
56
|
});
|
|
114
57
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
expect(find).not.toHaveBeenCalled();
|
|
121
|
-
});
|
|
58
|
+
it("ref 以 '/' 结尾(如 'provider/')→ null,不调 find", () => {
|
|
59
|
+
const find = vi.fn((_provider: string, _modelId: string): Model<Api> | undefined => undefined);
|
|
60
|
+
const ctx = makeCtx({ find, hasConfiguredAuth: () => true });
|
|
61
|
+
expect(resolveModel(ctx, { type: "ref", ref: "provider/" })).toBeNull();
|
|
62
|
+
expect(find).not.toHaveBeenCalled();
|
|
122
63
|
});
|
|
123
64
|
});
|
package/src/call.ts
CHANGED
|
@@ -14,8 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
// 顶层静态 import —— 探针①已验证加载阶段不 throw(见模块注释)
|
|
16
16
|
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
17
|
-
import type {
|
|
18
|
-
|
|
17
|
+
import type {
|
|
18
|
+
Context as LlmContext,
|
|
19
|
+
SimpleStreamOptions,
|
|
20
|
+
} from "@earendil-works/pi-ai/compat";
|
|
21
|
+
import type { Api, Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
19
22
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
23
|
|
|
21
24
|
// ──────────────────────── 类型 ────────────────────────
|
|
@@ -33,6 +36,12 @@ export interface CallLLMOptions {
|
|
|
33
36
|
timeoutMs?: number;
|
|
34
37
|
/** 透传给 SimpleStreamOptions.sessionId(provider 用于 session 缓存 / 路由)。review TF1 新增。 */
|
|
35
38
|
sessionId?: string;
|
|
39
|
+
/**
|
|
40
|
+
* thinking/reasoning 级别,透传给 SimpleStreamOptions.reasoning(pi 的 THINKING_ORDER SSOT:
|
|
41
|
+
* minimal/low/medium/high/xhigh/max)。"off" 表示关闭 thinking,由本库映射为「不传 reasoning 字段」
|
|
42
|
+
* (provider 默认行为);不传 = 同样 provider 默认。
|
|
43
|
+
*/
|
|
44
|
+
reasoning?: ModelThinkingLevel;
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
/**
|
|
@@ -113,6 +122,7 @@ export async function callLLM(
|
|
|
113
122
|
...(opts.maxTokens ? { maxTokens: opts.maxTokens } : {}),
|
|
114
123
|
...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}),
|
|
115
124
|
...(opts.sessionId ? { sessionId: opts.sessionId } : {}),
|
|
125
|
+
...(opts.reasoning && opts.reasoning !== "off" ? { reasoning: opts.reasoning } : {}),
|
|
116
126
|
};
|
|
117
127
|
const resp = await completeSimple(opts.model, context, options);
|
|
118
128
|
// G3/C1a:completeSimple 对 error/aborted 也 resolve(带 stopReason,不 reject)。
|
package/src/config.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync,
|
|
|
23
23
|
import { dirname, join } from "node:path";
|
|
24
24
|
|
|
25
25
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
26
|
+
import { withFileLockSync } from "@zhushanwen/pi-file-lock";
|
|
26
27
|
|
|
27
28
|
/** JSON 序列化缩进格数(permission/config.ts 同款)。 */
|
|
28
29
|
const JSON_INDENT = 2;
|
|
@@ -114,13 +115,35 @@ export function loadConfig<T>(
|
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
// ────────────────────────
|
|
118
|
+
// ──────────────────────── 保存(锁内原子写) ────────────────────────
|
|
118
119
|
|
|
119
120
|
/**
|
|
120
|
-
*
|
|
121
|
+
* 生成并发唯一的 tmp 文件名(D1e 附带风险修复:双侧 tmp 中间文件同名 `<path>.tmp`
|
|
122
|
+
* 并发可碰撞——runtime 侧 atomicWrite 未传 uniqueSuffix 也是固定名,扩展侧唯一化
|
|
123
|
+
* 后两侧名字空间不相交,碰撞面消除)。
|
|
124
|
+
*
|
|
125
|
+
* 后缀 = pid + 36 进制随机段:同进程多写方(多 session)与跨进程写方均不重名。
|
|
126
|
+
*/
|
|
127
|
+
const TMP_RANDOM_BASE = 36;
|
|
128
|
+
const TMP_RANDOM_SLICE_START = 2; // 跳过 Math.random 字符串的 "0." 前缀
|
|
129
|
+
const TMP_RANDOM_SLICE_END = 10;
|
|
130
|
+
function uniqueTmpPath(configPath: string): string {
|
|
131
|
+
return `${configPath}.tmp_${process.pid}_${Math.random().toString(TMP_RANDOM_BASE).slice(TMP_RANDOM_SLICE_START, TMP_RANDOM_SLICE_END)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 保存配置(锁内原子写:withFileLockSync + tmp 文件 + rename)。
|
|
121
136
|
*
|
|
122
137
|
* @returns 成功 {success:true};失败 {success:false, error}
|
|
123
138
|
*
|
|
139
|
+
* 🔒 跨进程锁(D1e/W4,integrity-hardening.md §3.1,登记表 §6 rename-session 行):
|
|
140
|
+
* ext-config 家族被 xyz runtime(如 setRenameModel 写 model 字段,W1b 已持锁)与
|
|
141
|
+
* pi 子进程内扩展(本函数)双写。互斥只依赖同一 lockfile(<config>.lock),本侧
|
|
142
|
+
* withFileLockSync 协议与 runtime 侧 settings.json 写锁逐字对齐(realpath:false +
|
|
143
|
+
* stale 30s + busy-wait 1s 预算 fail-fast)。锁获取失败不降级无锁写——对端
|
|
144
|
+
* runtime 可能正持锁写,无锁写会交错丢字段;返回 {success:false} 由调用方按
|
|
145
|
+
* 保存失败处理(下次 save 重试)。
|
|
146
|
+
*
|
|
124
147
|
* 原子性:writeFileSync(tmp) + renameSync(tmp→target),rename 是原子的(POSIX/Windows)。
|
|
125
148
|
* tmp 失败清理(review RK3):writeFileSync 或 renameSync 抛错时,catch 块 unlinkSync(tmp)
|
|
126
149
|
* 清理残留 tmp 文件(unlink 本身 try/catch,避免二次抛错)。
|
|
@@ -137,44 +160,55 @@ export function saveConfig(
|
|
|
137
160
|
onWarning?: (msg: string) => void,
|
|
138
161
|
): { success: boolean; error?: string } {
|
|
139
162
|
const configPath = getConfigPath(pkgName);
|
|
140
|
-
const tmpPath =
|
|
163
|
+
const tmpPath = uniqueTmpPath(configPath);
|
|
141
164
|
const content = `${JSON.stringify(config, null, JSON_INDENT)}\n`;
|
|
142
165
|
|
|
143
|
-
|
|
144
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
145
|
-
writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
146
|
-
renameSync(tmpPath, configPath);
|
|
147
|
-
|
|
148
|
-
// 写后更新缓存(用新文件 mtime+size + 写入的 config)
|
|
166
|
+
const writeLocked = (): { success: boolean; error?: string } => {
|
|
149
167
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
169
|
+
writeFileSync(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
|
|
170
|
+
renameSync(tmpPath, configPath);
|
|
171
|
+
|
|
172
|
+
// 写后更新缓存(用新文件 mtime+size + 写入的 config)
|
|
173
|
+
try {
|
|
174
|
+
const newStat = statSync(configPath);
|
|
175
|
+
configCache.set(configPath, {
|
|
176
|
+
mtimeMs: newStat.mtimeMs,
|
|
177
|
+
size: newStat.size,
|
|
178
|
+
config: clone(config),
|
|
179
|
+
});
|
|
180
|
+
} catch (statErr) {
|
|
181
|
+
// stat 失败不影响保存成功;缓存下次 load 时会重读
|
|
182
|
+
console.warn(
|
|
183
|
+
`[llm-shared] saveConfig stat after write failed:`,
|
|
184
|
+
statErr instanceof Error ? statErr.message : String(statErr),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { success: true };
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// RK3: 清理残留 tmp 文件(writeFileSync 或 renameSync 失败时 tmp 可能残留)
|
|
191
|
+
try {
|
|
192
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
193
|
+
} catch (cleanupErr) {
|
|
194
|
+
// tmp 清理失败不能阻塞保存失败的返回;记录原因
|
|
195
|
+
console.warn(
|
|
196
|
+
`[llm-shared] saveConfig tmp cleanup failed:`,
|
|
197
|
+
cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
onWarning?.(`[llm-shared] Failed to save config at '${configPath}': ${message}`);
|
|
202
|
+
return { success: false, error: `Failed to save config at '${configPath}': ${message}` };
|
|
162
203
|
}
|
|
204
|
+
};
|
|
163
205
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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}` };
|
|
206
|
+
try {
|
|
207
|
+
return withFileLockSync(configPath, writeLocked);
|
|
208
|
+
} catch (lockErr) {
|
|
209
|
+
// 锁获取失败(ELOCKED 预算耗尽等):不降级无锁写(见 docstring),按保存失败返回
|
|
210
|
+
const message = lockErr instanceof Error ? lockErr.message : String(lockErr);
|
|
211
|
+
onWarning?.(`[llm-shared] Config write lock unavailable at '${configPath}': ${message}`);
|
|
212
|
+
return { success: false, error: `Config write lock unavailable at '${configPath}': ${message}` };
|
|
179
213
|
}
|
|
180
214
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// @zhushanwen/pi-llm-shared —— 统一 public API 出口。
|
|
2
|
-
// resolve:
|
|
2
|
+
// resolve: 模型解析(仅 ref 精确指定)
|
|
3
3
|
// call: LLM 调用(completeSimple + 凭证 + 文本提取)
|
|
4
4
|
// config: 泛型配置读写(mtime 缓存 + 原子写)
|
|
5
|
-
export { resolveModel,
|
|
5
|
+
export { resolveModel, type ModelSelector } from "./resolve.ts";
|
|
6
6
|
export { callLLM, extractText, type CallLLMOptions, type CallLLMResult } from "./call.ts";
|
|
7
7
|
export { getConfigPath, loadConfig, saveConfig, clearConfigCache } from "./config.ts";
|
|
8
8
|
export { migrateLegacyConfig, type MigrationResult } from "./migrate.ts";
|
package/src/resolve.ts
CHANGED
|
@@ -1,83 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 模型解析:把 ModelSelector
|
|
2
|
+
* 模型解析:把 ModelSelector(仅 ref 精确指定)解析成可用的 Model,或 null(不可用,调用方静默跳过)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* 只支持精确指定 provider/modelId;不再支持 fallback / available / scoped。
|
|
5
|
+
* 需要自动选模的调用方(如 permission 的 "auto")应在自己这一层基于 ctx.modelRegistry 实现,
|
|
6
|
+
* 不通过 ModelSelector 表达非精确语义。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { readFileSync } from "node:fs";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
|
|
12
9
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
13
|
-
import {
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
11
|
|
|
15
12
|
// ──────────────────────── 类型 ────────────────────────
|
|
16
13
|
|
|
17
14
|
/**
|
|
18
|
-
*
|
|
15
|
+
* 模型选择器:只支持精确指定。
|
|
19
16
|
* - 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
17
|
*/
|
|
59
|
-
export
|
|
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
|
-
}
|
|
18
|
+
export type ModelSelector = { type: "ref"; ref: string };
|
|
81
19
|
|
|
82
20
|
// ──────────────────────── 模型解析 ────────────────────────
|
|
83
21
|
|
|
@@ -98,50 +36,11 @@ function resolveRef(ctx: ExtensionContext, ref: string): Model<Api> | null {
|
|
|
98
36
|
return model;
|
|
99
37
|
}
|
|
100
38
|
|
|
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
39
|
/**
|
|
138
|
-
* 按 selector
|
|
40
|
+
* 按 selector 解析模型。返回 null = 不可用,调用方静默跳过(不抛错)。
|
|
139
41
|
*
|
|
140
42
|
* 走 ctx.modelRegistry(pi 三源合并后的模型注册表)。hasConfiguredAuth 过滤掉未配置凭证的模型。
|
|
141
43
|
*/
|
|
142
44
|
export function resolveModel(ctx: ExtensionContext, selector: ModelSelector): Model<Api> | null {
|
|
143
|
-
|
|
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"
|
|
45
|
+
return resolveRef(ctx, selector.ref);
|
|
147
46
|
}
|
|
@@ -1,139 +0,0 @@
|
|
|
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
|
-
});
|