dsh-code-server-app 0.3.59 → 0.3.65

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.
@@ -0,0 +1,603 @@
1
+ /**
2
+ * lib/fim-adapter.mjs —— 实验性「FIM(幽灵)补全」的宿主侧:**一个注册进 DSH 的 LLM 适配器**(0.3.61)。
3
+ *
4
+ * 来源与许可:本文件**为独立实现,不含 continuedev/continue 的源码**;但其中的设计(停顿去抖、
5
+ * 光标附近窗口、结果过滤/单行化、有界缓存、按文件禁用)与该设置项划分**参考**了该项目
6
+ * (Apache License 2.0, Copyright 2023 Continue)。详见仓库根 `THIRD_PARTY_NOTICES.md`。
7
+ *
8
+ * ## 为什么是"注册自己的适配器"而不是直连(方案 A)
9
+ *
10
+ * FIM 走的是 **Completions API**(`POST /beta/completions`,参数 `prompt` + `suffix`),而 DSH 的
11
+ * `ctx.llm.stream(GenerateOptions)` 词汇表里**只有 `messages`**、`purpose` 是封闭联合
12
+ * (`'compaction' | 'session-title'`,见 `@deepseek-ai/dsh-llm` 的 `lib/types/types.d.ts:439-479`)——
13
+ * **表达不了这个请求**。把 FIM 变成一等请求要改 DSH 源码;在插件里能做的有两条:
14
+ * ① **注册一个自己的 `LlmAdapter` 路由,用 messages 承载 prefix/suffix**(本文件,采纳);
15
+ * ② 直连 `fetch`(更短,但绕开 DSH 的 LLM 服务层:没有 `llm/stream` waterfall、没有取消契约、
16
+ * 没有统一错误码,而且凭据与 attribution 语义要自己再实现一遍)。
17
+ * ① 的代价是那条"信封"约定(见下),好处是**这条调用仍然是一次正常的模型调用**:取消、超时、
18
+ * 终态 chunk、错误码全按服务的契约走,将来 DSH 把 completion 做成一等请求时,只要把
19
+ * `stream()` 里的取数换成新 API 即可,调用方(`lib/index.js` 的 `/complete` 路由)一行都不用改。
20
+ *
21
+ * ## 信封(方案 A 唯一的"丑",所以写得尽量显式)
22
+ *
23
+ * `GenerateOptions.messages` 里放**一条 user 消息**,正文是:
24
+ *
25
+ * dshcs-fim/1 {"prompt":"…","suffix":"…","language":"typescript","path":"src/a.ts"}
26
+ *
27
+ * 前缀是固定字面量(便于 grep / 单测 / 排障),载荷是 JSON。适配器**只认**这种正文:
28
+ * 解不出信封就 `LlmError('…', 'INVALID_REQUEST')` —— 误用要**响**,不能静默降级成一次普通对话。
29
+ *
30
+ * ## 用量口径(最容易写错的一处)
31
+ *
32
+ * DSH 的 `TokenUsage` 是**互斥**计数(`types.d.ts:141-163`):`inputTokens` 只算**未缓存**输入,
33
+ * 缓存读/写单独报;而 DeepSeek 的 `prompt_tokens` 是**含缓存的合计** ⇒ 必须减出去(`mapFimUsage`)。
34
+ * 写错的表现是数字虚高、缓存收益看不见。
35
+ *
36
+ * ## 计量会落在哪(必须知道,不然会以为"没记账")
37
+ *
38
+ * 一次性插件调用**不是 loop 请求**(`isAgentLoopRequest` 为假),所以它**不会**写进会话日志 ⇒
39
+ * `dsh-token-meter` 的逐轮/会话投影与遥测里**都没有它**(token-meter 只读会话事件,
40
+ * 见 `dsh-token-meter/lib/index.js:397-398`)。因此本文件自带 `createFimStats()`,
41
+ * 由状态栏把"这轮补全花了多少"显示给用户 —— 这是**唯一**的可见处。
42
+ *
43
+ * ## 边界(全部有界;这条链路会被每次击键触发)
44
+ *
45
+ * 前缀/后缀窗口、输出长度、单次超时、并发、速率都有上限,别放宽。
46
+ */
47
+
48
+ import { loadDshExport, loadDshModule } from './dsh-resolve.mjs';
49
+
50
+ /** 我们注册的 provider 路由名(与官方的 `deepseek-official` 并列;同名会 DUPLICATE_ADAPTER)。 */
51
+ export const FIM_PROVIDER = 'dshcs-fim';
52
+
53
+ /** 官方 FIM 文档里的示例模型,也是本部署的默认模型。 */
54
+ export const FIM_MODEL = 'deepseek-flash';
55
+
56
+ /** Beta 基址(官方要求 base_url 带 /beta 才开 Beta 功能)。 */
57
+ export const FIM_BASE_URL = 'https://api.deepseek.com/beta';
58
+
59
+ /** 凭据引用名:与官方适配器的 `apiKeyEnv` 默认值同名 ⇒ 用户不需要再配一次。 */
60
+ export const FIM_CREDENTIAL_REF = 'DEEPSEEK_API_KEY';
61
+
62
+ /** 信封前缀(改这里等于改线上约定:扩展侧不参与,只有宿主两侧)。 */
63
+ export const FIM_ENVELOPE_PREFIX = 'dshcs-fim/1 ';
64
+
65
+ /** 前缀/后缀窗口(字符与行数取先到者;扩展侧也有一份同名常量,由 scripts/test-fim.mjs 钉住一致性)。 */
66
+ export const FIM_MAX_PREFIX_CHARS = 6000;
67
+ export const FIM_MAX_SUFFIX_CHARS = 2000;
68
+ export const FIM_MAX_PREFIX_LINES = 120;
69
+ export const FIM_MAX_SUFFIX_LINES = 40;
70
+
71
+ /** 输出上限与清洗上限(官方 FIM 最大 4K,但我们只要"插在光标处的一小段")。 */
72
+ export const FIM_MAX_TOKENS = 128;
73
+ export const FIM_MAX_OUTPUT_CHARS = 2000;
74
+
75
+ /** 单次超时(实测 112–416ms;4s 是"慢到不该再用"的判定线)。 */
76
+ export const FIM_TIMEOUT_MS = 4000;
77
+
78
+ /** 声明的模型上下文窗口(只是给服务做校验用的;我们的请求远小于它)。 */
79
+ export const FIM_CONTEXT_WINDOW = 131072;
80
+
81
+ /** 停顿窗口(可设):太短会在打字过程中反复触发,太长就失去"补全"的意义。 */
82
+ export const FIM_DEBOUNCE_DEFAULT_MS = 250;
83
+ export const FIM_DEBOUNCE_MIN_MS = 100;
84
+ export const FIM_DEBOUNCE_MAX_MS = 3000;
85
+
86
+ /** 把停顿值夹到合法范围(设置是用户填的,不能让 5ms 或 10^9 这样的值打到链路上)。
87
+ * **空值(undefined / null / '' / 非数字)回落默认值**,而不是被 `Number(null)===0` 夹成下限 ——
88
+ * 输入框被清空是"没填",不是"要 100ms"。 */
89
+ export function clampDebounce(value) {
90
+ if (value === undefined || value === null || value === '') return FIM_DEBOUNCE_DEFAULT_MS;
91
+ const n = Number(value);
92
+ if (!Number.isFinite(n)) return FIM_DEBOUNCE_DEFAULT_MS;
93
+ return Math.min(FIM_DEBOUNCE_MAX_MS, Math.max(FIM_DEBOUNCE_MIN_MS, Math.round(n)));
94
+ }
95
+
96
+ /** 解析"按 glob 禁用"清单(分号 / 逗号 / 空白 / 换行分隔,与认领类型同一套书写习惯)。空串 = 不禁用。 */
97
+ export function compileGlobList(text) {
98
+ if (typeof text !== 'string' || text.trim() === '') return [];
99
+ const seen = new Set();
100
+ const out = [];
101
+ for (const raw of text.split(/[;,\s]+/)) {
102
+ const item = raw.trim().replace(/\\/g, '/').replace(/^\.\//, '');
103
+ if (item === '' || seen.has(item)) continue;
104
+ seen.add(item);
105
+ out.push(item);
106
+ }
107
+ return out;
108
+ }
109
+
110
+ /**
111
+ * glob → 正则(语义刻意做得小且可预测,别加"聪明"规则):
112
+ * - `*` 不跨目录;`**` 跨目录(含 0 层 ⇒ `**​/x` 也匹配 `x`);`?` 单个非 `/` 字符
113
+ * - **不含 `/` 的模式只匹配文件名**(`*.md` 命中 `a.md` 与 `src/a.md`);含 `/` 的匹配**完整路径**
114
+ * - 以 `/` 结尾视作 `/**`(`node_modules/` 符合直觉)
115
+ * - 大小写敏感,不做 Windows 特例
116
+ * @returns {{re: RegExp, anchored: boolean}|null} null = 空模式
117
+ */
118
+ export function globToRegExp(glob) {
119
+ let g = typeof glob === 'string' ? glob.trim().replace(/\\/g, '/').replace(/^\.\//, '') : '';
120
+ if (g === '') return null;
121
+ if (g.endsWith('/')) g += '**';
122
+ const anchored = g.includes('/');
123
+ let out = '';
124
+ for (let i = 0; i < g.length; i += 1) {
125
+ const ch = g[i];
126
+ if (ch === '*') {
127
+ if (g[i + 1] === '*') {
128
+ if (g[i + 2] === '/') {
129
+ out += '(?:.*/)?'; // `**/` = 任意层目录(含 0 层)
130
+ i += 2;
131
+ } else {
132
+ out += '.*';
133
+ i += 1;
134
+ }
135
+ } else {
136
+ out += '[^/]*';
137
+ }
138
+ } else if (ch === '?') {
139
+ out += '[^/]';
140
+ } else {
141
+ out += ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
142
+ }
143
+ }
144
+ return { re: new RegExp(`^${out}$`), anchored };
145
+ }
146
+
147
+ /**
148
+ * 路径是否命中禁用清单。
149
+ *
150
+ * 含 `/` 的模式**按完整路径与各层尾段各试一次**:用户写 `vendor/**`、`src/*.ts` 的直觉是
151
+ * "任何层级下的它",而不是"仅根目录";只有这样写才不会静默失效(绝对路径永远以 `/` 或盘符开头)。
152
+ * 不含 `/` 的模式只看文件名(与 .gitignore 的直觉一致)。
153
+ *
154
+ * @returns {string|null} 命中的那条模式(便于回给用户/写日志),未命中返回 null
155
+ */
156
+ export function matchDisabledGlob(filePath, globs) {
157
+ if (typeof filePath !== 'string' || filePath === '') return null;
158
+ const list = Array.isArray(globs) ? globs : compileGlobList(globs);
159
+ if (list.length === 0) return null;
160
+ const full = filePath.replace(/\\/g, '/');
161
+ const slash = full.lastIndexOf('/');
162
+ const base = slash === -1 ? full : full.slice(slash + 1);
163
+ for (const glob of list) {
164
+ const parsed = globToRegExp(glob);
165
+ if (parsed === null) continue;
166
+ if (!parsed.anchored) {
167
+ if (parsed.re.test(base)) return glob;
168
+ continue;
169
+ }
170
+ if (parsed.re.test(full)) return glob;
171
+ for (let i = full.indexOf('/'); i !== -1; i = full.indexOf('/', i + 1)) {
172
+ if (parsed.re.test(full.slice(i + 1))) return glob;
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /** 只保留第一行(设置里关掉"允许多行"时用)。首行是空白 ⇒ 空串,调用方据此当作"这次不补"。 */
179
+ export function toSingleLine(text) {
180
+ if (typeof text !== 'string' || text === '') return '';
181
+ const first = text.split('\n')[0];
182
+ return first.trim() === '' ? '' : first;
183
+ }
184
+
185
+ /** 审计/归属:`attributionHeaders()` 是适配器契约要求的头,解不到就退回空对象并记账(见 stats.notes)。 */
186
+ export const FIM_PKG = 'dsh-code-server-app';
187
+
188
+ // ---------------------------------------------------------------- 纯函数(全部可单测)
189
+
190
+ /** 造信封正文。 */
191
+ export function encodeFimRequest(payload) {
192
+ const clean = {
193
+ prompt: typeof payload?.prompt === 'string' ? payload.prompt : '',
194
+ suffix: typeof payload?.suffix === 'string' ? payload.suffix : '',
195
+ };
196
+ if (typeof payload?.language === 'string' && payload.language !== '') clean.language = payload.language;
197
+ if (typeof payload?.path === 'string' && payload.path !== '') clean.path = payload.path;
198
+ return FIM_ENVELOPE_PREFIX + JSON.stringify(clean);
199
+ }
200
+
201
+ /**
202
+ * 解信封:不是信封、JSON 坏、字段类型不对 ⇒ null(调用方据此报 INVALID_REQUEST)。
203
+ * 前缀不匹配时**不看**后面的内容,避免把普通对话误当作补全请求。
204
+ */
205
+ export function decodeFimRequest(text) {
206
+ if (typeof text !== 'string' || !text.startsWith(FIM_ENVELOPE_PREFIX)) return null;
207
+ let parsed;
208
+ try {
209
+ parsed = JSON.parse(text.slice(FIM_ENVELOPE_PREFIX.length));
210
+ } catch {
211
+ return null;
212
+ }
213
+ if (parsed === null || typeof parsed !== 'object') return null;
214
+ if (typeof parsed.prompt !== 'string' || typeof parsed.suffix !== 'string') return null;
215
+ return {
216
+ prompt: parsed.prompt,
217
+ suffix: parsed.suffix,
218
+ language: typeof parsed.language === 'string' ? parsed.language : '',
219
+ path: typeof parsed.path === 'string' ? parsed.path : '',
220
+ };
221
+ }
222
+
223
+ /**
224
+ * 从 `GenerateOptions.messages` 里找信封。
225
+ *
226
+ * 只看 text 块;第一条命中即返回(我们不接受"多条信封"这种用法 —— 一次调用一次补全)。
227
+ * @returns {{prompt: string, suffix: string, language: string, path: string}|null}
228
+ */
229
+ export function extractFimEnvelope(messages) {
230
+ if (!Array.isArray(messages)) return null;
231
+ for (const message of messages) {
232
+ const content = message?.content;
233
+ if (!Array.isArray(content)) continue;
234
+ for (const block of content) {
235
+ if (block?.type !== 'text' || typeof block.text !== 'string') continue;
236
+ const decoded = decodeFimRequest(block.text);
237
+ if (decoded !== null) return decoded;
238
+ }
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /** 把文本裁到窗口内(`fromEnd=true` 表示保留靠近光标的那一端)。 */
244
+ export function trimWindow(text, { maxChars, maxLines, fromEnd }) {
245
+ if (typeof text !== 'string' || text === '') return '';
246
+ const chars = Number.isSafeInteger(maxChars) && maxChars > 0 ? maxChars : 0;
247
+ const lines = Number.isSafeInteger(maxLines) && maxLines > 0 ? maxLines : 0;
248
+ let out = text;
249
+ if (lines > 0) {
250
+ const split = out.split('\n');
251
+ if (split.length > lines) out = (fromEnd ? split.slice(split.length - lines) : split.slice(0, lines)).join('\n');
252
+ }
253
+ if (chars > 0 && out.length > chars) out = fromEnd ? out.slice(out.length - chars) : out.slice(0, chars);
254
+ return out;
255
+ }
256
+
257
+ /** 特殊/控制标记:`<|…|>` 与 `<|…|>` 两族(实测见过 DeepSeek 吐出 `<||DSML|| parameter>`)。 */
258
+ const CONTROL_TOKEN_RE = /<[/\\]?[||]+[^<>]*?[||]+\s*[a-z_-]*\s*>/gi;
259
+
260
+ /**
261
+ * 清洗模型输出,使它**可以直接插在光标处**。
262
+ *
263
+ * 处理的四类脏(前两类实测见过,后两类零成本兜住):
264
+ * ① 控制标记(上面的正则);
265
+ * ② 代码围栏(```lang … ```)—— chat 路由上必现,原生 FIM 上未复现;
266
+ * ③ CRLF 归一;
267
+ * ④ 长度上限。
268
+ * **不做**的事:不 trim 行首缩进(那是补全内容的一部分)、不猜"这段像不像代码"
269
+ * (假阳性由编辑器侧的判据兜,见 extension 侧 `lib/fim-completion.js`)。
270
+ */
271
+ export function cleanFimCompletion(text) {
272
+ if (typeof text !== 'string' || text === '') return '';
273
+ let out = text.replace(/\r\n?/g, '\n').replace(CONTROL_TOKEN_RE, '');
274
+ const fenced = out.match(/^\s*```[^\n]*\n([\s\S]*?)\n?```\s*$/);
275
+ if (fenced !== null) out = fenced[1];
276
+ else out = out.replace(/^\s*```[^\n]*\n/, '').replace(/\n```\s*$/, '');
277
+ if (out.length > FIM_MAX_OUTPUT_CHARS) out = out.slice(0, FIM_MAX_OUTPUT_CHARS);
278
+ return out;
279
+ }
280
+
281
+ /**
282
+ * provider usage → DSH 的互斥口径(见文件头)。
283
+ * 缺字段按 0;`cacheRead` 不会超过 `prompt`;总数不自洽时按 `prompt + completion` 算。
284
+ */
285
+ export function mapFimUsage(raw) {
286
+ const num = (value) => (Number.isFinite(value) && value > 0 ? Math.floor(value) : 0);
287
+ const prompt = num(raw?.prompt_tokens);
288
+ const completion = num(raw?.completion_tokens);
289
+ const cacheRead = Math.min(num(raw?.prompt_cache_hit_tokens), prompt);
290
+ const totalRaw = num(raw?.total_tokens);
291
+ return {
292
+ inputTokens: Math.max(0, prompt - cacheRead),
293
+ outputTokens: completion,
294
+ cacheReadTokens: cacheRead,
295
+ cacheWriteTokens: 0,
296
+ totalTokens: totalRaw >= prompt + completion ? totalRaw : prompt + completion,
297
+ };
298
+ }
299
+
300
+ /** 计数器(状态栏只有这一处可见;见文件头"计量会落在哪")。 */
301
+ export function createFimStats() {
302
+ const state = {
303
+ calls: 0,
304
+ ok: 0,
305
+ failed: 0,
306
+ inputTokens: 0,
307
+ outputTokens: 0,
308
+ cacheReadTokens: 0,
309
+ totalTokens: 0,
310
+ lastMs: null,
311
+ lastError: null,
312
+ lastAt: null,
313
+ };
314
+ return {
315
+ recordOk(usage, ms) {
316
+ state.calls += 1;
317
+ state.ok += 1;
318
+ state.inputTokens += usage?.inputTokens ?? 0;
319
+ state.outputTokens += usage?.outputTokens ?? 0;
320
+ state.cacheReadTokens += usage?.cacheReadTokens ?? 0;
321
+ state.totalTokens += usage?.totalTokens ?? 0;
322
+ state.lastMs = Number.isFinite(ms) ? Math.round(ms) : null;
323
+ state.lastError = null;
324
+ state.lastAt = Date.now();
325
+ return state;
326
+ },
327
+ recordFailure(reason, ms) {
328
+ state.calls += 1;
329
+ state.failed += 1;
330
+ state.lastMs = Number.isFinite(ms) ? Math.round(ms) : null;
331
+ state.lastError = typeof reason === 'string' ? reason : 'unknown';
332
+ state.lastAt = Date.now();
333
+ return state;
334
+ },
335
+ snapshot() {
336
+ return { ...state };
337
+ },
338
+ };
339
+ }
340
+
341
+ /** 速率/并发闸(这条链路由击键触发,必须有)。 */
342
+ export function createFimBudget({ minIntervalMs = 120, maxInflight = 1, callsPerMinute = 60, now = () => Date.now() } = {}) {
343
+ let inflight = 0;
344
+ let lastAt = 0;
345
+ /** 最近一分钟的调用时间戳(滑动窗口)。 */
346
+ const recent = [];
347
+ return {
348
+ /** @returns {'ok'|'busy'|'too-fast'|'rate-limited'} */
349
+ acquire() {
350
+ if (inflight >= maxInflight) return 'busy';
351
+ const t = now();
352
+ if (lastAt !== 0 && t - lastAt < minIntervalMs) return 'too-fast';
353
+ while (recent.length > 0 && t - recent[0] > 60000) recent.shift();
354
+ if (recent.length >= callsPerMinute) return 'rate-limited';
355
+ inflight += 1;
356
+ lastAt = t;
357
+ recent.push(t);
358
+ return 'ok';
359
+ },
360
+ release() {
361
+ inflight = Math.max(0, inflight - 1);
362
+ },
363
+ snapshot() {
364
+ const t = now();
365
+ return { inflight, callsLastMinute: recent.filter((at) => t - at <= 60000).length };
366
+ },
367
+ };
368
+ }
369
+
370
+ // ---------------------------------------------------------------- 线上取数
371
+
372
+ /**
373
+ * 发一次 FIM 请求(非流式)。
374
+ *
375
+ * 为什么**不流式**:实测同一题 `stream:true` 首字 691–810ms、总 833–1045ms,而 `stream:false`
376
+ * 整份只要 112–416ms —— 非流式反而更快,而且补全本来就是"一小段要一次到位"。
377
+ * 为什么**不设 stop**:4/4 全部自然收尾(`finish=stop`),任何 stop 串都可能把合法的多行补全腰斩。
378
+ *
379
+ * @returns {Promise<{ok: true, text: string, usage: object, finish: string|null, ms: number}
380
+ * | {ok: false, code: string, message: string, status?: number, ms: number}>}
381
+ */
382
+ export async function callFimWire({
383
+ fetchImpl,
384
+ apiKey,
385
+ baseUrl = FIM_BASE_URL,
386
+ model = FIM_MODEL,
387
+ payload,
388
+ maxTokens = FIM_MAX_TOKENS,
389
+ headers = {},
390
+ signal,
391
+ timeoutMs = FIM_TIMEOUT_MS,
392
+ }) {
393
+ const started = Date.now();
394
+ const timeout = AbortSignal.timeout(timeoutMs);
395
+ const combined = signal === undefined ? timeout : AbortSignal.any([timeout, signal]);
396
+ let response;
397
+ try {
398
+ response = await fetchImpl(`${baseUrl}/completions`, {
399
+ method: 'POST',
400
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}`, ...headers },
401
+ body: JSON.stringify({
402
+ model,
403
+ prompt: payload.prompt,
404
+ suffix: payload.suffix,
405
+ max_tokens: maxTokens,
406
+ temperature: 0,
407
+ stream: false,
408
+ }),
409
+ signal: combined,
410
+ });
411
+ } catch (error) {
412
+ const ms = Date.now() - started;
413
+ if (signal !== undefined && signal.aborted === true) return { ok: false, code: 'ABORTED', message: '补全请求被取消', ms };
414
+ if (timeout.aborted === true) return { ok: false, code: 'TRANSPORT', message: `补全请求超时(${timeoutMs}ms)`, ms };
415
+ return { ok: false, code: 'TRANSPORT', message: `补全请求失败:${error?.message ?? String(error)}`, ms };
416
+ }
417
+ const ms = Date.now() - started;
418
+ if (response.ok !== true) {
419
+ let detail = '';
420
+ try {
421
+ detail = (await response.text()).slice(0, 300);
422
+ } catch {
423
+ detail = '';
424
+ }
425
+ const code = response.status === 401 || response.status === 403 ? 'AUTH'
426
+ : response.status === 429 ? 'RATE_LIMIT'
427
+ : response.status >= 500 ? 'PROVIDER' : 'INVALID_REQUEST';
428
+ return { ok: false, code, message: `补全端点 HTTP ${response.status}${detail === '' ? '' : `:${detail}`}`, status: response.status, ms };
429
+ }
430
+ let json;
431
+ try {
432
+ json = await response.json();
433
+ } catch (error) {
434
+ return { ok: false, code: 'PROVIDER', message: `补全响应不是 JSON:${error?.message ?? String(error)}`, ms };
435
+ }
436
+ const choice = Array.isArray(json?.choices) ? json.choices[0] : null;
437
+ if (choice === null || typeof choice !== 'object') return { ok: false, code: 'PROVIDER', message: '补全响应缺少 choices', ms };
438
+ return {
439
+ ok: true,
440
+ text: cleanFimCompletion(typeof choice.text === 'string' ? choice.text : ''),
441
+ usage: mapFimUsage(json?.usage),
442
+ finish: typeof choice.finish_reason === 'string' ? choice.finish_reason : null,
443
+ ms,
444
+ };
445
+ }
446
+
447
+ // ---------------------------------------------------------------- 适配器工厂
448
+
449
+ /** 解析基类与辅助(解析不到 = 这个 DSH 没有对应服务 ⇒ 调用方把 FIM 判为不可用)。 */
450
+ export async function loadFimBase() {
451
+ const [Base, attribution, assertKey, createUserMessage] = await Promise.all([
452
+ loadDshExport('@deepseek-ai/dsh-llm', 'LlmAdapter'),
453
+ loadDshExport('@deepseek-ai/dsh-llm', 'attributionHeaders'),
454
+ loadDshExport('@deepseek-ai/dsh-llm', 'assertUsableApiKey'),
455
+ loadDshExport('@deepseek-ai/dsh-llm', 'createUserMessage'),
456
+ ]);
457
+ if (typeof Base !== 'function') return null;
458
+ return {
459
+ Base,
460
+ attributionHeaders: typeof attribution === 'function' ? attribution : null,
461
+ assertUsableApiKey: typeof assertKey === 'function' ? assertKey : null,
462
+ createUserMessage: typeof createUserMessage === 'function' ? createUserMessage : null,
463
+ };
464
+ }
465
+
466
+ /** `LlmError`(用于"请求非法"这类要响的错误;取不到就退化成普通 Error)。 */
467
+ async function loadLlmError() {
468
+ const mod = await loadDshModule('@deepseek-ai/dsh-llm');
469
+ return typeof mod?.LlmError === 'function' ? mod.LlmError : null;
470
+ }
471
+
472
+ /** 造一个终态 error/aborted chunk(契约:每条流都以一个 finish 收尾)。 */
473
+ function failureChunk(kind, code, message, status) {
474
+ const failure = { code, message, ...(Number.isSafeInteger(status) ? { status } : {}) };
475
+ return { type: 'finish', reason: kind === 'aborted' ? { kind: 'aborted', failure } : { kind: 'error', failure } };
476
+ }
477
+
478
+ /**
479
+ * 造适配器类(`Base` 必须在运行时解析,所以类也是运行时造的)。
480
+ *
481
+ * @param {Function} Base `LlmAdapter` 基类
482
+ * @param {{resolveApiKey: () => Promise<string>, fetchImpl?: Function, model?: string,
483
+ * baseUrl?: string, attributionHeaders?: Function|null, assertUsableApiKey?: Function|null,
484
+ * timeoutMs?: number, log?: (message: string) => void}} deps
485
+ */
486
+ export function createFimAdapterClass(Base, deps) {
487
+ const log = deps.log ?? (() => {});
488
+ const model = deps.model ?? FIM_MODEL;
489
+ const fetchImpl = deps.fetchImpl ?? ((...args) => fetch(...args));
490
+
491
+ return class FimCompletionAdapter extends Base {
492
+ providerInfo(provider) {
493
+ return { id: provider, name: 'DSH FIM(实验性)' };
494
+ }
495
+
496
+ listModels(provider) {
497
+ return Promise.resolve([{ provider, id: model, name: `DeepSeek FIM (${model})` }]);
498
+ }
499
+
500
+ resolveModel(provider, id) {
501
+ return Promise.resolve({
502
+ provider,
503
+ id,
504
+ name: `DeepSeek FIM (${id})`,
505
+ description: '实验性:走 Completions API 的 FIM 补全,只由编辑器补全使用',
506
+ inputModalities: ['text'],
507
+ context: { contextWindow: FIM_CONTEXT_WINDOW },
508
+ defaultMaxTokens: FIM_MAX_TOKENS,
509
+ });
510
+ }
511
+
512
+ prepareCall(provider, id) {
513
+ const resolved = {
514
+ provider,
515
+ id,
516
+ name: `DeepSeek FIM (${id})`,
517
+ context: { contextWindow: FIM_CONTEXT_WINDOW },
518
+ defaultMaxTokens: FIM_MAX_TOKENS,
519
+ };
520
+ return Promise.resolve({ model: resolved, stream: (options) => this.stream(options) });
521
+ }
522
+
523
+ /**
524
+ * 唯一必填的方法。**永不抛**:所有失败都以契约要求的终态 chunk 收尾,
525
+ * 调用方(桥的 /complete 路由)因此只有一条取值路径。
526
+ */
527
+ async *stream(options) {
528
+ const payload = extractFimEnvelope(options?.messages);
529
+ if (payload === null) {
530
+ log('收到不是信封的请求:拒绝(避免把普通对话当成补全)');
531
+ yield failureChunk('error', 'INVALID_REQUEST', 'dshcs-fim 只接受 FIM 信封请求');
532
+ return;
533
+ }
534
+ const prompt = trimWindow(payload.prompt, { maxChars: FIM_MAX_PREFIX_CHARS, maxLines: FIM_MAX_PREFIX_LINES, fromEnd: true });
535
+ const suffix = trimWindow(payload.suffix, { maxChars: FIM_MAX_SUFFIX_CHARS, maxLines: FIM_MAX_SUFFIX_LINES, fromEnd: false });
536
+
537
+ let apiKey;
538
+ try {
539
+ apiKey = await deps.resolveApiKey();
540
+ if (deps.assertUsableApiKey !== null && deps.assertUsableApiKey !== undefined) {
541
+ apiKey = deps.assertUsableApiKey(apiKey, FIM_PKG, FIM_CREDENTIAL_REF);
542
+ }
543
+ } catch (error) {
544
+ yield failureChunk('error', 'MISSING_CREDENTIAL', error?.message ?? '缺少凭据');
545
+ return;
546
+ }
547
+
548
+ let headers = {};
549
+ try {
550
+ if (typeof deps.attributionHeaders === 'function') headers = deps.attributionHeaders() ?? {};
551
+ } catch {
552
+ headers = {};
553
+ }
554
+
555
+ const wire = await callFimWire({
556
+ fetchImpl,
557
+ apiKey,
558
+ baseUrl: deps.baseUrl ?? FIM_BASE_URL,
559
+ model: options?.model ?? model,
560
+ payload: { prompt, suffix },
561
+ maxTokens: Number.isSafeInteger(options?.maxTokens) ? options.maxTokens : FIM_MAX_TOKENS,
562
+ headers,
563
+ signal: options?.signal,
564
+ timeoutMs: deps.timeoutMs ?? FIM_TIMEOUT_MS,
565
+ });
566
+
567
+ if (wire.ok !== true) {
568
+ yield failureChunk(wire.code === 'ABORTED' ? 'aborted' : 'error', wire.code, wire.message, wire.status);
569
+ return;
570
+ }
571
+
572
+ // 块协议:block-start → text-delta → usage → block-end → finish(usage 必须先于 finish)。
573
+ yield { type: 'block-start', index: 0, blockType: 'text' };
574
+ if (wire.text !== '') yield { type: 'text-delta', index: 0, text: wire.text };
575
+ yield { type: 'usage', usage: wire.usage };
576
+ yield { type: 'block-end', index: 0, block: { type: 'text', text: wire.text } };
577
+ yield { type: 'finish', reason: { kind: wire.finish === 'length' ? 'max-tokens' : 'stop' } };
578
+ }
579
+ };
580
+ }
581
+
582
+ /**
583
+ * 解析凭据(与官方适配器同一条路:`credentials.resolve` → 启动环境变量 → fail closed)。
584
+ * @param {object} ctx cordis 上下文
585
+ * @param {string} [ref]
586
+ */
587
+ export function createApiKeyResolver(ctx, ref = FIM_CREDENTIAL_REF) {
588
+ return async () => {
589
+ let credentials = null;
590
+ try {
591
+ credentials = typeof ctx?.get === 'function' ? ctx.get('credentials') : null;
592
+ } catch {
593
+ credentials = null;
594
+ }
595
+ if (credentials !== null && credentials !== undefined && typeof credentials.resolve === 'function') {
596
+ const hit = await credentials.resolve(ref);
597
+ if (hit !== undefined && hit !== null && typeof hit.value === 'string' && hit.value !== '') return hit.value;
598
+ }
599
+ const ambient = process.env[ref];
600
+ if (typeof ambient === 'string' && ambient.length > 0) return ambient;
601
+ throw new Error(`没有可用的 ${ref}(请在 DSH 的模型设置里存一次凭据,或在启动环境里导出它)`);
602
+ };
603
+ }