@zhushanwen/pi-llm-shared 0.6.0 → 0.8.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 +4 -3
- package/src/__tests__/call.test.ts +39 -1
- package/src/__tests__/config.test.ts +1 -1
- package/src/__tests__/resolve.test.ts +85 -1
- package/src/call.ts +24 -6
- package/src/config.ts +6 -5
- package/src/index.ts +11 -4
- package/src/resolve.ts +50 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-llm-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Shared LLM invocation library for Pi extensions — model resolution (ref exact only), LLM calling (completeSimple), and config read/write with mtime+size caching. Shared library, not a Pi extension.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -17,8 +17,9 @@
|
|
|
17
17
|
"src/"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@zhushanwen/pi-extension-logger": "0.
|
|
21
|
-
"@zhushanwen/pi-
|
|
20
|
+
"@zhushanwen/pi-extension-logger": "0.6.0",
|
|
21
|
+
"@zhushanwen/pi-ext-guards": "0.4.0",
|
|
22
|
+
"@zhushanwen/pi-file-lock": "0.4.0"
|
|
22
23
|
},
|
|
23
24
|
"peerDependencies": {
|
|
24
25
|
"@earendil-works/pi-ai": "^0.84.4",
|
|
@@ -3,7 +3,7 @@ import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
|
3
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
|
|
6
|
-
import { callLLM, extractText } from "../call.ts";
|
|
6
|
+
import { callLLM, extractText, joinTextBlocks } from "../call.ts";
|
|
7
7
|
|
|
8
8
|
// mock completeSimple —— call.ts 顶层静态 import 会拿到此 mock(探针①已验证静态 import 机制可行,
|
|
9
9
|
// 此处验证 callLLM 逻辑:凭证 narrow / options 构造 / 文本提取 / 错误归一化)。
|
|
@@ -281,3 +281,41 @@ describe("extractText", () => {
|
|
|
281
281
|
expect(extractText({ content: [] })).toBe("");
|
|
282
282
|
});
|
|
283
283
|
});
|
|
284
|
+
|
|
285
|
+
describe("joinTextBlocks(unknown 安全内核,D7)", () => {
|
|
286
|
+
it("text block 过滤拼接:只取 type==='text',join(' '),不 trim", () => {
|
|
287
|
+
expect(
|
|
288
|
+
joinTextBlocks([
|
|
289
|
+
{ type: "text", text: " a" },
|
|
290
|
+
{ type: "thinking", text: "ignored" },
|
|
291
|
+
{ type: "text", text: "b " },
|
|
292
|
+
]),
|
|
293
|
+
).toBe(" a b ");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("非 text block(thinking / tool_call)忽略", () => {
|
|
297
|
+
expect(joinTextBlocks([{ type: "thinking", text: "x" }, { type: "tool_call" }])).toBe("");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("text 字段缺失的 text block 按 '' 拼接", () => {
|
|
301
|
+
expect(joinTextBlocks([{ type: "text" }, { type: "text", text: "x" }])).toBe(" x");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("空数组 → ''", () => {
|
|
305
|
+
expect(joinTextBlocks([])).toBe("");
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it.each([
|
|
309
|
+
["undefined", undefined],
|
|
310
|
+
["null", null],
|
|
311
|
+
["字符串", "hello"],
|
|
312
|
+
["数字", 42],
|
|
313
|
+
["单个 block 对象(非数组)", { type: "text", text: "x" }],
|
|
314
|
+
])("非数组输入 %s → 安全返回 ''(不 throw)", (_label, raw) => {
|
|
315
|
+
expect(joinTextBlocks(raw)).toBe("");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("数组内非法元素(null / 非 block 对象)被过滤,不 throw", () => {
|
|
319
|
+
expect(joinTextBlocks([null, 42, { type: "text", text: "kept" }])).toBe("kept");
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -21,7 +21,7 @@ vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
|
21
21
|
// 用 vi.mock 包装 readFileSync/renameSync/statSync/unlinkSync(默认走 actual,
|
|
22
22
|
// 个别 test override),其他 fs 操作(writeFileSync/existsSync/mkdirSync...)原样
|
|
23
23
|
// 透传 actual。statSync/unlinkSync 供 U4 logger.warn 留痕用例 override。
|
|
24
|
-
// 注意:
|
|
24
|
+
// 注意:withFileLockSync 内部为自实现 mkdir-lock(lock-core.ts,零依赖直用 node:fs,非 proper-lockfile);本 mock 默认透传 actual,锁行为不受影响。
|
|
25
25
|
vi.mock("node:fs", async (importOriginal) => {
|
|
26
26
|
const actual = await importOriginal() as typeof import("node:fs");
|
|
27
27
|
return {
|
|
@@ -2,7 +2,7 @@ import type { Api, Model } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
4
|
|
|
5
|
-
import { resolveModel } from "../resolve.ts";
|
|
5
|
+
import { isThinkingLevel, normalizeModelSelector, parseModelRef, resolveModel } from "../resolve.ts";
|
|
6
6
|
|
|
7
7
|
/** 构造最小 Model(cast 绕过必填字段,单测只关心 provider/id)。 */
|
|
8
8
|
function makeModel(provider: string, id: string): Model<Api> {
|
|
@@ -62,3 +62,87 @@ describe("resolveModel(仅 ref 精确指定)", () => {
|
|
|
62
62
|
expect(find).not.toHaveBeenCalled();
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
describe("isThinkingLevel(V2 七值钉值,与 pi-ai ModelThinkingLevel 联合一致)", () => {
|
|
67
|
+
it.each(["off", "minimal", "low", "medium", "high", "xhigh", "max"])(
|
|
68
|
+
"合法值 %j → true",
|
|
69
|
+
(level) => {
|
|
70
|
+
expect(isThinkingLevel(level)).toBe(true);
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
it.each([
|
|
75
|
+
["未知档位 ultra", "ultra"],
|
|
76
|
+
["空串", ""],
|
|
77
|
+
["大小写不符 OFF", "OFF"],
|
|
78
|
+
["undefined", undefined],
|
|
79
|
+
["null", null],
|
|
80
|
+
["数字", 1],
|
|
81
|
+
["对象", {}],
|
|
82
|
+
["数组(元素为合法值也不接受)", ["high"]],
|
|
83
|
+
])("非法值 %s → false", (_label, raw) => {
|
|
84
|
+
expect(isThinkingLevel(raw)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("类型收窄:合法值通过谓词后可赋给 ModelThinkingLevel", () => {
|
|
88
|
+
const raw: unknown = "xhigh";
|
|
89
|
+
if (isThinkingLevel(raw)) {
|
|
90
|
+
const narrowed: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" = raw;
|
|
91
|
+
expect(narrowed).toBe("xhigh");
|
|
92
|
+
} else {
|
|
93
|
+
throw new Error("xhigh 应通过谓词");
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("parseModelRef(V2 五形态钉值,ext-simplify-18 D4)", () => {
|
|
99
|
+
it("合法 ref → {provider, modelId}", () => {
|
|
100
|
+
expect(parseModelRef("anthropic/claude-5.3")).toEqual({ provider: "anthropic", modelId: "claude-5.3" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("缺斜杠('foo')→ null", () => {
|
|
104
|
+
expect(parseModelRef("foo")).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("尾空 modelId('provider/')→ null", () => {
|
|
108
|
+
expect(parseModelRef("provider/")).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("首空 provider('/model')→ null", () => {
|
|
112
|
+
expect(parseModelRef("/model")).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("modelId 含斜杠('a/b/c')→ 取首个 / 分隔 → {provider:'a', modelId:'b/c'}", () => {
|
|
116
|
+
expect(parseModelRef("a/b/c")).toEqual({ provider: "a", modelId: "b/c" });
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("normalizeModelSelector", () => {
|
|
121
|
+
it("合法 ref selector → 通过(仅取 type/ref 两字段)", () => {
|
|
122
|
+
expect(normalizeModelSelector({ type: "ref", ref: "deepseek-router/deepseek-chat" })).toEqual({
|
|
123
|
+
type: "ref",
|
|
124
|
+
ref: "deepseek-router/deepseek-chat",
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("多余字段不透传(构造干净对象)", () => {
|
|
129
|
+
expect(normalizeModelSelector({ type: "ref", ref: "a/b", extra: 1 })).toEqual({
|
|
130
|
+
type: "ref",
|
|
131
|
+
ref: "a/b",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it.each([
|
|
136
|
+
["字符串", "ref"],
|
|
137
|
+
["数字", 42],
|
|
138
|
+
["null", null],
|
|
139
|
+
["undefined", undefined],
|
|
140
|
+
["数组", [{ type: "ref", ref: "a/b" }]],
|
|
141
|
+
["type 非 ref", { type: "available" }],
|
|
142
|
+
["ref 非字符串", { type: "ref", ref: 123 }],
|
|
143
|
+
["缺 ref", { type: "ref" }],
|
|
144
|
+
["空对象", {}],
|
|
145
|
+
])("%s → null", (_label, raw) => {
|
|
146
|
+
expect(normalizeModelSelector(raw)).toBeNull();
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/call.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from "@earendil-works/pi-ai/compat";
|
|
21
21
|
import type { Api, Message, Model, ModelThinkingLevel, Usage } from "@earendil-works/pi-ai";
|
|
22
22
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
23
24
|
|
|
24
25
|
// ──────────────────────── 类型 ────────────────────────
|
|
25
26
|
|
|
@@ -61,20 +62,37 @@ export type CallLLMResult =
|
|
|
61
62
|
|
|
62
63
|
// ──────────────────────── 文本提取 ────────────────────────
|
|
63
64
|
|
|
65
|
+
/** 类型谓词:content 数组元素是否为 text block(text 字段可缺失,拼接时按 "" 处理)。 */
|
|
66
|
+
function isTextBlock(block: unknown): block is { type: string; text?: string } {
|
|
67
|
+
return isRecord(block) && block.type === "text";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* unknown 安全的 text block 拼接内核:过滤 `type==="text"` 的 block 并 `join(" ")`(不 trim)。
|
|
72
|
+
*
|
|
73
|
+
* 非数组输入(含 undefined / 非法 block 元素)安全返回 "",供消费方对任意 LLM 响应/会话
|
|
74
|
+
* entry 形状直接调用(ext-simplify-17 D7——rename-session 等包的本地 joinTextBlocks 副本收敛于此)。
|
|
75
|
+
* trim 是调用方策略(extractText trim,其余调用方自定),内核不含。
|
|
76
|
+
*/
|
|
77
|
+
export function joinTextBlocks(content: unknown): string {
|
|
78
|
+
if (!Array.isArray(content)) return "";
|
|
79
|
+
return content
|
|
80
|
+
.filter(isTextBlock)
|
|
81
|
+
.map((block) => block.text ?? "")
|
|
82
|
+
.join(" ");
|
|
83
|
+
}
|
|
84
|
+
|
|
64
85
|
/**
|
|
65
86
|
* 从 AssistantMessage.content 提取所有 text block 拼接并 trim。
|
|
66
87
|
*
|
|
67
88
|
* 参数用结构类型(不直接依赖 AssistantMessage),便于测试 mock —— 调用方传 completeSimple 返回值即可。
|
|
68
89
|
* 无 text block(如纯 ThinkingContent / ToolCall)→ 返回 ""。
|
|
90
|
+
* trim 契约不变:内部委托 joinTextBlocks 内核再 trim(ext-simplify-17 D7,行为等价由等价探针验证)。
|
|
69
91
|
*/
|
|
70
92
|
export function extractText(resp: {
|
|
71
93
|
content: ReadonlyArray<{ type: string; text?: string }>;
|
|
72
94
|
}): string {
|
|
73
|
-
return resp.content
|
|
74
|
-
.filter((block) => block.type === "text")
|
|
75
|
-
.map((block) => block.text ?? "")
|
|
76
|
-
.join(" ")
|
|
77
|
-
.trim();
|
|
95
|
+
return joinTextBlocks(resp.content).trim();
|
|
78
96
|
}
|
|
79
97
|
|
|
80
98
|
// ──────────────────────── 调用 ────────────────────────
|
|
@@ -143,6 +161,6 @@ export async function callLLM(
|
|
|
143
161
|
...(resp.usage ? { usage: resp.usage } : {}),
|
|
144
162
|
};
|
|
145
163
|
} catch (error) {
|
|
146
|
-
return { ok: false, error:
|
|
164
|
+
return { ok: false, error: toErrorMessage(error) };
|
|
147
165
|
}
|
|
148
166
|
}
|
package/src/config.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync,
|
|
|
25
25
|
import { dirname, join } from "node:path";
|
|
26
26
|
|
|
27
27
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
28
29
|
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
29
30
|
import { withFileLockSync } from "@zhushanwen/pi-file-lock";
|
|
30
31
|
|
|
@@ -112,7 +113,7 @@ export function loadConfig<T>(
|
|
|
112
113
|
configCache.set(configPath, { mtimeMs: stat.mtimeMs, size: stat.size, config });
|
|
113
114
|
return clone(config);
|
|
114
115
|
} catch (error) {
|
|
115
|
-
const message =
|
|
116
|
+
const message = toErrorMessage(error);
|
|
116
117
|
onWarning?.(`[llm-shared] Config parse failed at '${configPath}', using default: ${message}`);
|
|
117
118
|
// 缓存 defaults + 当前 mtime/size,避免每次重读损坏文件(mtime 变化时缓存自动失效)
|
|
118
119
|
configCache.set(configPath, { mtimeMs: stat.mtimeMs, size: stat.size, config: clone(defaults) });
|
|
@@ -184,7 +185,7 @@ export function saveConfig(
|
|
|
184
185
|
});
|
|
185
186
|
} catch (statErr) {
|
|
186
187
|
// stat 失败不影响保存成功;缓存下次 load 时会重读
|
|
187
|
-
logger.warn("saveConfig stat after write failed", { detail: { err:
|
|
188
|
+
logger.warn("saveConfig stat after write failed", { detail: { err: toErrorMessage(statErr) } });
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
return { success: true };
|
|
@@ -194,9 +195,9 @@ export function saveConfig(
|
|
|
194
195
|
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
195
196
|
} catch (cleanupErr) {
|
|
196
197
|
// tmp 清理失败不能阻塞保存失败的返回;记录原因
|
|
197
|
-
logger.warn("saveConfig tmp cleanup failed", { detail: { err:
|
|
198
|
+
logger.warn("saveConfig tmp cleanup failed", { detail: { err: toErrorMessage(cleanupErr) } });
|
|
198
199
|
}
|
|
199
|
-
const message =
|
|
200
|
+
const message = toErrorMessage(error);
|
|
200
201
|
onWarning?.(`[llm-shared] Failed to save config at '${configPath}': ${message}`);
|
|
201
202
|
return { success: false, error: `Failed to save config at '${configPath}': ${message}` };
|
|
202
203
|
}
|
|
@@ -206,7 +207,7 @@ export function saveConfig(
|
|
|
206
207
|
return withFileLockSync(configPath, writeLocked);
|
|
207
208
|
} catch (lockErr) {
|
|
208
209
|
// 锁获取失败(ELOCKED 预算耗尽等):不降级无锁写(见 docstring),按保存失败返回
|
|
209
|
-
const message =
|
|
210
|
+
const message = toErrorMessage(lockErr);
|
|
210
211
|
onWarning?.(`[llm-shared] Config write lock unavailable at '${configPath}': ${message}`);
|
|
211
212
|
return { success: false, error: `Config write lock unavailable at '${configPath}': ${message}` };
|
|
212
213
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// @zhushanwen/pi-llm-shared —— 统一 public API 出口。
|
|
2
|
-
// resolve: 模型解析(仅 ref
|
|
2
|
+
// resolve: 模型解析(仅 ref 精确指定)+ selector 归一化 + thinking 级别校验
|
|
3
3
|
// call: LLM 调用(completeSimple + 凭证 + 文本提取)
|
|
4
4
|
// config: 泛型配置读写(mtime+size 双 key 缓存 + 原子写)
|
|
5
|
-
export {
|
|
6
|
-
|
|
5
|
+
export {
|
|
6
|
+
resolveModel,
|
|
7
|
+
parseModelRef,
|
|
8
|
+
getCurrentModelId,
|
|
9
|
+
normalizeModelSelector,
|
|
10
|
+
isThinkingLevel,
|
|
11
|
+
type ModelSelector,
|
|
12
|
+
} from "./resolve.ts";
|
|
13
|
+
export { callLLM, joinTextBlocks, extractText, type CallLLMOptions, type CallLLMResult } from "./call.ts";
|
|
7
14
|
export { getConfigPath, loadConfig, saveConfig, clearConfigCache } from "./config.ts";
|
|
8
|
-
export { migrateLegacyConfig
|
|
15
|
+
export { migrateLegacyConfig } from "./migrate.ts";
|
package/src/resolve.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* 不通过 ModelSelector 表达非精确语义。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
9
|
+
import type { Api, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
10
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { isRecord } from "@zhushanwen/pi-ext-guards";
|
|
11
12
|
|
|
12
13
|
// ──────────────────────── 类型 ────────────────────────
|
|
13
14
|
|
|
@@ -17,10 +18,56 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
17
18
|
*/
|
|
18
19
|
export type ModelSelector = { type: "ref"; ref: string };
|
|
19
20
|
|
|
21
|
+
// ──────────────────────── selector 归一化 ────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 从 unknown(如 JSON.parse 的配置文件内容)恢复 ModelSelector,非法形态返回 null。
|
|
25
|
+
*
|
|
26
|
+
* 只接受 `{ type: "ref", ref: string }`(仅取这两个字段,多余字段不透传);非对象 /
|
|
27
|
+
* 数组 / type 非 "ref" / ref 非字符串 → null,调用方自行决定回退(如 `?? 默认值`)。
|
|
28
|
+
* canonical 来自 ext-simplify-17 D6——rename-session / smart-context 两份同构本地
|
|
29
|
+
* 副本的公共核心谓词(排数组严版,与 ext-guards isRecord 语义一致)。
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeModelSelector(raw: unknown): ModelSelector | null {
|
|
32
|
+
if (!isRecord(raw)) return null;
|
|
33
|
+
if (raw.type === "ref" && typeof raw.ref === "string") {
|
|
34
|
+
return { type: "ref", ref: raw.ref };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ──────────────────────── thinking level 校验 ────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 合法 thinking 级别清单(与 pi-ai ModelThinkingLevel 七值联合一致;normalize 校验用)。
|
|
43
|
+
* Set 免 as 断言。
|
|
44
|
+
*/
|
|
45
|
+
const THINKING_LEVELS: ReadonlySet<string> = new Set([
|
|
46
|
+
"off",
|
|
47
|
+
"minimal",
|
|
48
|
+
"low",
|
|
49
|
+
"medium",
|
|
50
|
+
"high",
|
|
51
|
+
"xhigh",
|
|
52
|
+
"max",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 类型谓词:unknown 是否为合法 thinking 级别(配置 normalize 校验用,单点断言)。
|
|
57
|
+
* Set.has 运行时兜底 + 类型收窄,调用方无需再断言。
|
|
58
|
+
*
|
|
59
|
+
* extensions 侧唯一副本(ext-simplify-17 D5);与 packages/subagent-core 的
|
|
60
|
+
* THINKING_ORDER(src/shared/model-ref.ts)注释互指,不建跨包 import(分层约束:
|
|
61
|
+
* universal 角色包禁 import subagent-core,反向则 shared 库依赖 packages/ 破坏分层)。
|
|
62
|
+
*/
|
|
63
|
+
export function isThinkingLevel(raw: unknown): raw is ModelThinkingLevel {
|
|
64
|
+
return typeof raw === "string" && THINKING_LEVELS.has(raw);
|
|
65
|
+
}
|
|
66
|
+
|
|
20
67
|
// ──────────────────────── 模型解析 ────────────────────────
|
|
21
68
|
|
|
22
69
|
/** "provider/modelId" → 拆分(用 indexOf 而非 split,modelId 理论上可含 /,取首个 / 作分隔)。 */
|
|
23
|
-
function
|
|
70
|
+
export function parseModelRef(ref: string): { provider: string; modelId: string } | null {
|
|
24
71
|
const idx = ref.indexOf("/");
|
|
25
72
|
if (idx <= 0 || idx >= ref.length - 1) return null; // 缺 / 或前后为空
|
|
26
73
|
return { provider: ref.slice(0, idx), modelId: ref.slice(idx + 1) };
|
|
@@ -28,7 +75,7 @@ function parseRef(ref: string): { provider: string; modelId: string } | null {
|
|
|
28
75
|
|
|
29
76
|
/** ref 精确匹配:find 命中 + hasConfiguredAuth。任一失败返回 null(静默降级)。 */
|
|
30
77
|
function resolveRef(ctx: ExtensionContext, ref: string): Model<Api> | null {
|
|
31
|
-
const parsed =
|
|
78
|
+
const parsed = parseModelRef(ref);
|
|
32
79
|
if (!parsed) return null;
|
|
33
80
|
const model = ctx.modelRegistry.find(parsed.provider, parsed.modelId);
|
|
34
81
|
if (!model) return null;
|