@zhushanwen/pi-llm-shared 0.2.0 → 0.3.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 +2 -2
- package/src/__tests__/call.test.ts +41 -0
- package/src/__tests__/resolve.test.ts +28 -87
- package/src/call.ts +12 -2
- 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.0",
|
|
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": [
|
|
@@ -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;
|
|
@@ -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/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
|
-
});
|