pi-llamacpp-infra 1.0.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/LICENSE +21 -0
- package/README.md +255 -0
- package/package.json +37 -0
- package/src/index.ts +2428 -0
- package/src/prompt-warmup.ts +594 -0
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prompt-warmup — system-prompt header pre-cache for local llama.cpp servers
|
|
3
|
+
* ==========================================================================
|
|
4
|
+
*
|
|
5
|
+
* Internal module of the llamacpp-infra extension (previously the shared
|
|
6
|
+
* pi_extensions/lib/prompt-warmup.ts — inlined here since llamacpp-infra is
|
|
7
|
+
* its only consumer).
|
|
8
|
+
*
|
|
9
|
+
* The pi system prompt (identity, tools, skills, context files, docs paths)
|
|
10
|
+
* is a very large header (5k-25k tokens). On locally served models that
|
|
11
|
+
* prefill dominates time-to-first-token.
|
|
12
|
+
*
|
|
13
|
+
* llama.cpp-family servers (and forks: lucebox, ds4/DwarfStar, ZINC) keep the
|
|
14
|
+
* prompt KV cached in the slot between requests: if an incoming request
|
|
15
|
+
* shares a prefix with the previous one, the KV is reused and only the new
|
|
16
|
+
* tokens are evaluated. This module exploits that: when a new conversation
|
|
17
|
+
* starts (pi boot, /new, /fork, /resume or model switch) it sends the server
|
|
18
|
+
* a "warmup" request carrying the SAME header (system + tools captured from
|
|
19
|
+
* pi's real payload) with max_tokens=1, leaving the KV cached in the slot.
|
|
20
|
+
* The user's first real message then hits the cache: TTFT ~40s → ~0.3s.
|
|
21
|
+
*
|
|
22
|
+
* Key design point: the header is CAPTURED from the real payload in
|
|
23
|
+
* `before_provider_request` (not from ctx.getSystemPrompt()) because other
|
|
24
|
+
* extensions may transform it earlier. Reproducing the exact bytes of the
|
|
25
|
+
* payload guarantees an identical prefix.
|
|
26
|
+
*
|
|
27
|
+
* Templates persist in ~/.pi/agent/warmup-llamacpp-infra.json so the warmup
|
|
28
|
+
* survives pi restarts.
|
|
29
|
+
*
|
|
30
|
+
* Safety semantics:
|
|
31
|
+
* - Never blocks: everything is fire-and-forget, with AbortController.
|
|
32
|
+
* - The user's first real request aborts any in-flight warmup for that model.
|
|
33
|
+
* - Failures are silent (server down, timeout, 503): debug log only.
|
|
34
|
+
* - No user messages are stored: only the header (system + tools).
|
|
35
|
+
*
|
|
36
|
+
* Configuration (env):
|
|
37
|
+
* PI_WARMUP=0 globally disable warmup
|
|
38
|
+
* PI_WARMUP_LLAMACPP_INFRA=0 disable for this provider
|
|
39
|
+
* PI_WARMUP_FALLBACK=0 disable the ctx.getSystemPrompt() fallback
|
|
40
|
+
* PI_WARMUP_TIMEOUT_MS=180000 timeout per warmup request
|
|
41
|
+
* PI_WARMUP_COOLDOWN_MS=15000 cooldown between warmups of the same model
|
|
42
|
+
* PI_WARMUP_DEBUG=1 debug logging (console.debug → stderr)
|
|
43
|
+
*
|
|
44
|
+
* UI: silent by default. The llamacpp-infra extension passes its own key
|
|
45
|
+
* ("warmup-llamacpp-infra") to WarmupStatus for the ☕ footer indicator.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
import * as http from "node:http";
|
|
50
|
+
import * as fs from "node:fs";
|
|
51
|
+
import * as path from "node:path";
|
|
52
|
+
import { homedir } from "node:os";
|
|
53
|
+
import { URL } from "node:url";
|
|
54
|
+
|
|
55
|
+
// =============================================================================
|
|
56
|
+
// Constantes / env
|
|
57
|
+
// =============================================================================
|
|
58
|
+
|
|
59
|
+
const KIND_LLAMACPP = "llamacpp";
|
|
60
|
+
const KIND_LUCEBOX = "lucebox";
|
|
61
|
+
const KIND_DS4 = "ds4";
|
|
62
|
+
const KIND_ZINC = "zinc";
|
|
63
|
+
const KIND_LMSTUDIO = "lmstudio";
|
|
64
|
+
|
|
65
|
+
const SAVE_DEBOUNCE_MS = 5_000;
|
|
66
|
+
const MAX_TEMPLATES = 30;
|
|
67
|
+
const MAX_AGE_MS = 14 * 24 * 3600 * 1000; // 14 días
|
|
68
|
+
const PLACEHOLDER_USER = "hi";
|
|
69
|
+
|
|
70
|
+
function envBool(name: string, def: boolean): boolean {
|
|
71
|
+
const v = process.env[name];
|
|
72
|
+
if (v === undefined) return def;
|
|
73
|
+
return v === "1" || v.toLowerCase() === "true";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function envInt(name: string, def: number): number {
|
|
77
|
+
const v = process.env[name];
|
|
78
|
+
if (v === undefined) return def;
|
|
79
|
+
const n = parseInt(v, 10);
|
|
80
|
+
return isNaN(n) || n <= 0 ? def : n;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const GLOBAL_ENABLED = envBool("PI_WARMUP", true);
|
|
84
|
+
const FALLBACK_ENABLED = envBool("PI_WARMUP_FALLBACK", true);
|
|
85
|
+
const WARMUP_TIMEOUT_MS = envInt("PI_WARMUP_TIMEOUT_MS", 180_000);
|
|
86
|
+
const COOLDOWN_MS = envInt("PI_WARMUP_COOLDOWN_MS", 15_000);
|
|
87
|
+
/** Log de depuración: solo con PI_WARMUP_DEBUG=1. Las escrituras a stderr en
|
|
88
|
+
* TUI se intercalan con el render de pi y ensucian el editor / input box. */
|
|
89
|
+
const DEBUG_LOG_ENABLED = envBool("PI_WARMUP_DEBUG", false);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* ¿El servidor mantiene KV cache del prompt entre requests?
|
|
93
|
+
* - llama.cpp y forks (lucebox, ds4): sí (cache_prompt on por defecto).
|
|
94
|
+
* - LM Studio: sí, cache automática (backend llama.cpp) sin campo extra.
|
|
95
|
+
* - ZINC: server propio sin prefix-cache verificado → se descarta entero.
|
|
96
|
+
*/
|
|
97
|
+
function warmupEnabledFor(kind: string | undefined): boolean {
|
|
98
|
+
return kind !== KIND_ZINC;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** ¿Se manda el campo `cache_prompt` (extensión llama.cpp)? ZINC/LM Studio no lo necesitan. */
|
|
102
|
+
function cachePromptSupported(kind: string | undefined): boolean {
|
|
103
|
+
return kind !== KIND_ZINC && kind !== KIND_LMSTUDIO;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeBaseUrl(baseUrl: string | undefined): string {
|
|
107
|
+
if (!baseUrl) return "";
|
|
108
|
+
return baseUrl.replace(/\/+$/, "");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// Tipos
|
|
113
|
+
// =============================================================================
|
|
114
|
+
|
|
115
|
+
interface WarmMessage {
|
|
116
|
+
role: string;
|
|
117
|
+
content: unknown;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface WarmTemplate {
|
|
121
|
+
model: string;
|
|
122
|
+
baseUrl: string;
|
|
123
|
+
cwd: string;
|
|
124
|
+
systemMessages: WarmMessage[];
|
|
125
|
+
tools?: unknown[] | undefined;
|
|
126
|
+
kind: string;
|
|
127
|
+
capturedAt: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface WarmupBody {
|
|
131
|
+
model: string;
|
|
132
|
+
messages: WarmMessage[];
|
|
133
|
+
tools?: unknown[];
|
|
134
|
+
max_tokens: number;
|
|
135
|
+
temperature: number;
|
|
136
|
+
stream: boolean;
|
|
137
|
+
cache_prompt?: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface WarmupResult {
|
|
141
|
+
status: number;
|
|
142
|
+
body: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type WarmupEvent =
|
|
146
|
+
| { type: "start"; model: string }
|
|
147
|
+
| { type: "done"; model: string; ms: number; tokens?: number }
|
|
148
|
+
| { type: "abort"; model: string; ms: number }
|
|
149
|
+
| { type: "error"; model: string; ms: number; error: string };
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Indicador visual del warmup en el footer de pi (ctx.ui.setStatus).
|
|
153
|
+
*
|
|
154
|
+
* Indicador compacto y sin trazas en stderr:
|
|
155
|
+
* start → "☕" (mientras se precarga la cabecera)
|
|
156
|
+
* done → "☕ 30.2s ✓" (duración del prefill; se borra tras ~3s)
|
|
157
|
+
* error → "☕ ⚠" (id.)
|
|
158
|
+
* abort → clear inmediato
|
|
159
|
+
*
|
|
160
|
+
* El log verbose por stderr (PI_WARMUP_DEBUG=1) se ha desactivado por defecto
|
|
161
|
+
* porque las escrituras a stderr en TUI se intercalan con el render de pi y
|
|
162
|
+
* ensucian el editor / caja de input.
|
|
163
|
+
*
|
|
164
|
+
* Pasar siempre una `key` por provider (p.ej. "warmup-llamacpp-infra" / "warmup-lmstudio")
|
|
165
|
+
* para que dos providers activos a la vez no se pisen la misma ranura del footer.
|
|
166
|
+
*
|
|
167
|
+
* const warmupStatus = new WarmupStatus("warmup-llamacpp-infra");
|
|
168
|
+
* const warmer = new PromptWarmer({ ..., onEvent: (ev) => warmupStatus.handle(ev) });
|
|
169
|
+
* // en session_start / model_select:
|
|
170
|
+
* if (ctx.hasUI) warmupStatus.bind(ctx.ui);
|
|
171
|
+
* // en session_shutdown:
|
|
172
|
+
* warmupStatus.dispose();
|
|
173
|
+
*/
|
|
174
|
+
export class WarmupStatus {
|
|
175
|
+
private ui: { setStatus: (key: string, text: string | undefined) => void } | undefined;
|
|
176
|
+
private key: string;
|
|
177
|
+
private doneClearMs: number;
|
|
178
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param key Clave de la ranura del footer (cada provider usa la suya
|
|
182
|
+
* para no chocar con otros warmups activos simultáneos).
|
|
183
|
+
* @param doneClearMs Tiempo que permanece el indicador "✓" / "⚠" tras terminar
|
|
184
|
+
* el warmup antes de borrarse (ms).
|
|
185
|
+
*/
|
|
186
|
+
constructor(key = "warmup", doneClearMs = 3000) {
|
|
187
|
+
this.key = key;
|
|
188
|
+
this.doneClearMs = doneClearMs;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Vincula la UI (ctx.ui de un handler con hasUI). Idempotente. */
|
|
192
|
+
bind(ui: { setStatus: (key: string, text: string | undefined) => void }): void {
|
|
193
|
+
this.ui = ui;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private clearSoon(ms: number): void {
|
|
197
|
+
if (this.timer) clearTimeout(this.timer);
|
|
198
|
+
this.timer = setTimeout(() => {
|
|
199
|
+
this.timer = undefined;
|
|
200
|
+
this.ui?.setStatus(this.key, undefined);
|
|
201
|
+
}, ms);
|
|
202
|
+
this.timer.unref?.();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private clearNow(): void {
|
|
206
|
+
if (this.timer) clearTimeout(this.timer);
|
|
207
|
+
this.timer = undefined;
|
|
208
|
+
this.ui?.setStatus(this.key, undefined);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
handle(ev: WarmupEvent): void {
|
|
212
|
+
if (!this.ui) return;
|
|
213
|
+
switch (ev.type) {
|
|
214
|
+
case "start":
|
|
215
|
+
this.clearNow();
|
|
216
|
+
// Compacto: solo el icono. El usuario ve que algo se está precargando
|
|
217
|
+
// sin que el texto ocupe media línea del footer.
|
|
218
|
+
this.ui.setStatus(this.key, "☕");
|
|
219
|
+
break;
|
|
220
|
+
case "done": {
|
|
221
|
+
// Mostramos la duración del prefill (dato útil y corto).
|
|
222
|
+
// Sin token count interno: es ruido para el usuario.
|
|
223
|
+
const secs = (ev.ms / 1000).toFixed(1);
|
|
224
|
+
this.ui.setStatus(this.key, `☕ ${secs}s ✓`);
|
|
225
|
+
this.clearSoon(this.doneClearMs);
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case "abort":
|
|
229
|
+
this.clearNow();
|
|
230
|
+
break;
|
|
231
|
+
case "error":
|
|
232
|
+
this.ui.setStatus(this.key, "☕ ⚠");
|
|
233
|
+
this.clearSoon(this.doneClearMs);
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
dispose(): void {
|
|
239
|
+
this.clearNow();
|
|
240
|
+
this.ui = undefined;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface PromptWarmerOptions {
|
|
245
|
+
/** Nombre del provider registrado (llamacpp-infra / lmstudio). */
|
|
246
|
+
provider: string;
|
|
247
|
+
/** Ruta del fichero de plantillas persistido. */
|
|
248
|
+
cacheFile?: string;
|
|
249
|
+
/** Devuelve el tipo de servidor ("llamacpp"|"lucebox"|"ds4"|"zinc"|"lmstudio") para una baseUrl. */
|
|
250
|
+
kindFor?: (baseUrl: string) => string | undefined;
|
|
251
|
+
/** Callback opcional para eventos de warmup (estado en footer, notify, ...). */
|
|
252
|
+
onEvent?: (ev: WarmupEvent) => void;
|
|
253
|
+
/** Log opcional. Por defecto silencioso (no-op); activa con PI_WARMUP_DEBUG=1. */
|
|
254
|
+
log?: (msg: string) => void;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// =============================================================================
|
|
258
|
+
// HTTP mínimo (mismo patrón hardened que los providers locales)
|
|
259
|
+
// =============================================================================
|
|
260
|
+
// - family: 4 → evita la resolución IPv6-first de hostnames tailnet/LAN
|
|
261
|
+
// - Connection: close + agent:false → sin keep-alive (pasta/podman cierra sockets)
|
|
262
|
+
// - timeout largo (el prefill de 20k tokens tarda decenas de segundos)
|
|
263
|
+
// - AbortSignal → el primer request real aborta el warmup en vuelo
|
|
264
|
+
|
|
265
|
+
function warmupRequest(
|
|
266
|
+
baseUrl: string,
|
|
267
|
+
body: WarmupBody,
|
|
268
|
+
timeoutMs: number,
|
|
269
|
+
signal: AbortSignal,
|
|
270
|
+
): Promise<WarmupResult> {
|
|
271
|
+
return new Promise((resolve, reject) => {
|
|
272
|
+
let settled = false;
|
|
273
|
+
const url = new URL(normalizeBaseUrl(baseUrl) + "/chat/completions");
|
|
274
|
+
const options: http.RequestOptions = {
|
|
275
|
+
hostname: url.hostname,
|
|
276
|
+
port: url.port || (url.protocol === "https:" ? 443 : 80),
|
|
277
|
+
path: url.pathname + url.search,
|
|
278
|
+
method: "POST",
|
|
279
|
+
headers: {
|
|
280
|
+
"Content-Type": "application/json",
|
|
281
|
+
Accept: "application/json",
|
|
282
|
+
"User-Agent": "pi-warmup/1.0",
|
|
283
|
+
Connection: "close",
|
|
284
|
+
},
|
|
285
|
+
timeout: timeoutMs,
|
|
286
|
+
family: 4,
|
|
287
|
+
agent: false,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const req = http.request(options, (res) => {
|
|
291
|
+
const chunks: Buffer[] = [];
|
|
292
|
+
res.on("data", (c: Buffer) => chunks.push(c));
|
|
293
|
+
res.on("end", () => {
|
|
294
|
+
if (settled) return;
|
|
295
|
+
settled = true;
|
|
296
|
+
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString("utf-8") });
|
|
297
|
+
});
|
|
298
|
+
res.on("error", (e) => {
|
|
299
|
+
if (settled) return;
|
|
300
|
+
settled = true;
|
|
301
|
+
reject(e);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
req.on("error", (e) => {
|
|
306
|
+
if (settled) return;
|
|
307
|
+
settled = true;
|
|
308
|
+
reject(e);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
req.on("timeout", () => {
|
|
312
|
+
if (settled) return;
|
|
313
|
+
settled = true;
|
|
314
|
+
req.destroy();
|
|
315
|
+
const err: NodeJS.ErrnoException = new Error(`Warmup timeout after ${timeoutMs}ms`);
|
|
316
|
+
err.code = "WARMUP_TIMEOUT";
|
|
317
|
+
reject(err);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const onAbort = () => {
|
|
321
|
+
if (settled) return;
|
|
322
|
+
settled = true;
|
|
323
|
+
req.destroy();
|
|
324
|
+
const err: NodeJS.ErrnoException = new Error("Warmup aborted (primer mensaje del usuario)");
|
|
325
|
+
err.code = "ABORT_ERR";
|
|
326
|
+
reject(err);
|
|
327
|
+
};
|
|
328
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
329
|
+
req.on("close", () => signal.removeEventListener("abort", onAbort));
|
|
330
|
+
|
|
331
|
+
req.write(JSON.stringify(body));
|
|
332
|
+
req.end();
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parsePromptTokens(body: string): number | undefined {
|
|
337
|
+
try {
|
|
338
|
+
const usage = (JSON.parse(body) as { usage?: { prompt_tokens?: number } })?.usage;
|
|
339
|
+
if (typeof usage?.prompt_tokens === "number") return usage.prompt_tokens;
|
|
340
|
+
} catch {
|
|
341
|
+
// ignorar
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// =============================================================================
|
|
347
|
+
// PromptWarmer
|
|
348
|
+
// =============================================================================
|
|
349
|
+
|
|
350
|
+
export class PromptWarmer {
|
|
351
|
+
readonly provider: string;
|
|
352
|
+
private cacheFile: string;
|
|
353
|
+
private kindFor: (baseUrl: string) => string | undefined;
|
|
354
|
+
private onEvent?: (ev: WarmupEvent) => void;
|
|
355
|
+
private log: (msg: string) => void;
|
|
356
|
+
|
|
357
|
+
private templates = new Map<string, WarmTemplate>();
|
|
358
|
+
/** warmups en vuelo, key = modelId (abort rápido por modelo). */
|
|
359
|
+
private inFlight = new Map<string, AbortController>();
|
|
360
|
+
/** último warmup completado por key completa (baseUrl|model|cwd), para cooldown. */
|
|
361
|
+
private lastWarmed = new Map<string, number>();
|
|
362
|
+
private saveTimer: ReturnType<typeof setTimeout> | undefined;
|
|
363
|
+
private disposed = false;
|
|
364
|
+
private enabled: boolean;
|
|
365
|
+
|
|
366
|
+
constructor(opts: PromptWarmerOptions) {
|
|
367
|
+
this.provider = opts.provider;
|
|
368
|
+
this.cacheFile =
|
|
369
|
+
opts.cacheFile ?? path.join(homedir(), ".pi", "agent", `warmup-${opts.provider}.json`);
|
|
370
|
+
this.kindFor = opts.kindFor ?? (() => KIND_LLAMACPP);
|
|
371
|
+
this.onEvent = opts.onEvent;
|
|
372
|
+
// Por defecto silencioso: escribir por stderr corrompe el render de la TUI
|
|
373
|
+
// de pi (trazas sucias en el editor / input box). Solo se loguea con
|
|
374
|
+
// PI_WARMUP_DEBUG=1 o si el caller pasa un logger explícito.
|
|
375
|
+
this.log =
|
|
376
|
+
opts.log ??
|
|
377
|
+
(DEBUG_LOG_ENABLED ? (msg: string) => console.debug(msg) : () => {});
|
|
378
|
+
this.enabled =
|
|
379
|
+
GLOBAL_ENABLED && envBool(`PI_WARMUP_${opts.provider.toUpperCase()}`, true);
|
|
380
|
+
this.load();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
private key(modelId: string, baseUrl: string, cwd: string): string {
|
|
384
|
+
return `${baseUrl}|${modelId}|${cwd}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private kind(baseUrl: string): string | undefined {
|
|
388
|
+
try {
|
|
389
|
+
return this.kindFor(baseUrl);
|
|
390
|
+
} catch {
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ── Warmup ────────────────────────────────────────────────────────────────
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Llamar en session_start y model_select. Dispara (fire-and-forget) un
|
|
399
|
+
* prefill de la cabecera para el modelo actual si es de este provider.
|
|
400
|
+
* Usa la plantilla capturada (system+tools byte-idénticos) si existe;
|
|
401
|
+
* si no, fallback con ctx.getSystemPrompt() (prefijo parcial: pi-compact
|
|
402
|
+
* puede compactar skills después, pero el grueso de la cabecera coincide).
|
|
403
|
+
*/
|
|
404
|
+
warmupForModel(
|
|
405
|
+
model: { id: string; provider?: string; baseUrl?: string } | undefined,
|
|
406
|
+
systemPrompt?: string,
|
|
407
|
+
cwd?: string,
|
|
408
|
+
): void {
|
|
409
|
+
if (!this.enabled || this.disposed) return;
|
|
410
|
+
if (!model || !model.id || model.provider !== this.provider) return;
|
|
411
|
+
const baseUrl = normalizeBaseUrl(model.baseUrl);
|
|
412
|
+
if (!baseUrl) return;
|
|
413
|
+
if (!warmupEnabledFor(this.kind(baseUrl))) return;
|
|
414
|
+
|
|
415
|
+
const key = this.key(model.id, baseUrl, cwd ?? "");
|
|
416
|
+
const now = Date.now();
|
|
417
|
+
const last = this.lastWarmed.get(key);
|
|
418
|
+
if (last !== undefined && now - last < COOLDOWN_MS) return;
|
|
419
|
+
if (this.inFlight.has(model.id)) return;
|
|
420
|
+
|
|
421
|
+
const tpl = this.templates.get(key);
|
|
422
|
+
let body: WarmupBody;
|
|
423
|
+
if (tpl) {
|
|
424
|
+
body = {
|
|
425
|
+
model: tpl.model,
|
|
426
|
+
messages: [...tpl.systemMessages, { role: "user", content: PLACEHOLDER_USER }],
|
|
427
|
+
max_tokens: 1,
|
|
428
|
+
temperature: 0,
|
|
429
|
+
stream: false,
|
|
430
|
+
};
|
|
431
|
+
if (tpl.tools && tpl.tools.length > 0) body.tools = tpl.tools;
|
|
432
|
+
if (cachePromptSupported(tpl.kind)) body.cache_prompt = true;
|
|
433
|
+
} else if (systemPrompt && FALLBACK_ENABLED) {
|
|
434
|
+
body = {
|
|
435
|
+
model: model.id,
|
|
436
|
+
messages: [
|
|
437
|
+
{ role: "system", content: systemPrompt },
|
|
438
|
+
{ role: "user", content: PLACEHOLDER_USER },
|
|
439
|
+
],
|
|
440
|
+
max_tokens: 1,
|
|
441
|
+
temperature: 0,
|
|
442
|
+
stream: false,
|
|
443
|
+
};
|
|
444
|
+
if (cachePromptSupported(this.kind(baseUrl))) body.cache_prompt = true;
|
|
445
|
+
} else {
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
this.fire(model.id, key, baseUrl, body);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private fire(modelId: string, key: string, baseUrl: string, body: WarmupBody): void {
|
|
453
|
+
const ac = new AbortController();
|
|
454
|
+
this.inFlight.set(modelId, ac);
|
|
455
|
+
const t0 = Date.now();
|
|
456
|
+
this.onEvent?.({ type: "start", model: modelId });
|
|
457
|
+
this.log(`[warmup:${this.provider}] ${modelId} → ${normalizeBaseUrl(baseUrl)}/chat/completions`);
|
|
458
|
+
|
|
459
|
+
warmupRequest(baseUrl, body, WARMUP_TIMEOUT_MS, ac.signal)
|
|
460
|
+
.then((res) => {
|
|
461
|
+
if (this.disposed) return;
|
|
462
|
+
this.inFlight.delete(modelId);
|
|
463
|
+
this.lastWarmed.set(key, Date.now());
|
|
464
|
+
if (res.status >= 200 && res.status < 300) {
|
|
465
|
+
const tokens = parsePromptTokens(res.body);
|
|
466
|
+
const ms = Date.now() - t0;
|
|
467
|
+
this.onEvent?.({ type: "done", model: modelId, ms, tokens });
|
|
468
|
+
this.log(
|
|
469
|
+
`[warmup:${this.provider}] ${modelId}: prefill ${tokens ?? "?"} tok en ${(ms / 1000).toFixed(1)}s ✓`,
|
|
470
|
+
);
|
|
471
|
+
} else {
|
|
472
|
+
this.onEvent?.({ type: "error", model: modelId, ms: Date.now() - t0, error: `HTTP ${res.status}` });
|
|
473
|
+
this.log(`[warmup:${this.provider}] ${modelId}: HTTP ${res.status} (${res.body.slice(0, 120)})`);
|
|
474
|
+
}
|
|
475
|
+
})
|
|
476
|
+
.catch((err) => {
|
|
477
|
+
if (this.disposed) return;
|
|
478
|
+
this.inFlight.delete(modelId);
|
|
479
|
+
const ms = Date.now() - t0;
|
|
480
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
481
|
+
if (code === "ABORT_ERR") {
|
|
482
|
+
this.onEvent?.({ type: "abort", model: modelId, ms });
|
|
483
|
+
this.log(`[warmup:${this.provider}] ${modelId}: abortado (request real) tras ${(ms / 1000).toFixed(1)}s`);
|
|
484
|
+
} else {
|
|
485
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
486
|
+
this.onEvent?.({ type: "error", model: modelId, ms, error: msg });
|
|
487
|
+
this.log(`[warmup:${this.provider}] ${modelId}: ${msg}`);
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Aborta cualquier warmup en vuelo para un modelo (antes del request real). */
|
|
493
|
+
abortForModel(modelId: string): void {
|
|
494
|
+
const ac = this.inFlight.get(modelId);
|
|
495
|
+
if (ac) ac.abort();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// ── Captura del payload real ──────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Llamar en before_provider_request con el payload final (tras los hooks de
|
|
502
|
+
* otras extensiones) + la baseUrl del modelo. Aborta el warmup en vuelo y
|
|
503
|
+
* captura la cabecera real (system + tools) para futuras conversaciones.
|
|
504
|
+
*/
|
|
505
|
+
onProviderPayload(payload: unknown, baseUrl?: string, cwd?: string): void {
|
|
506
|
+
if (!this.enabled || this.disposed) return;
|
|
507
|
+
const p = payload as Record<string, unknown> | undefined;
|
|
508
|
+
if (!p || typeof p !== "object") return;
|
|
509
|
+
const modelId = typeof p.model === "string" ? p.model : undefined;
|
|
510
|
+
if (modelId) this.abortForModel(modelId);
|
|
511
|
+
if (!modelId || !baseUrl || !Array.isArray(p.messages)) return;
|
|
512
|
+
if (!warmupEnabledFor(this.kind(baseUrl))) return;
|
|
513
|
+
|
|
514
|
+
const msgs = p.messages as WarmMessage[];
|
|
515
|
+
const first = msgs[0];
|
|
516
|
+
if (!first || (first.role !== "system" && first.role !== "developer")) return;
|
|
517
|
+
|
|
518
|
+
const key = this.key(modelId, normalizeBaseUrl(baseUrl), cwd ?? "");
|
|
519
|
+
const tools = Array.isArray(p.tools) ? (p.tools as unknown[]) : undefined;
|
|
520
|
+
|
|
521
|
+
// No degradar una plantilla con tools por una captura sin tools.
|
|
522
|
+
const existing = this.templates.get(key);
|
|
523
|
+
if (existing && existing.tools && existing.tools.length > 0 && (!tools || tools.length === 0)) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
this.templates.set(key, {
|
|
528
|
+
model: modelId,
|
|
529
|
+
baseUrl: normalizeBaseUrl(baseUrl),
|
|
530
|
+
cwd: cwd ?? "",
|
|
531
|
+
systemMessages: msgs.filter((m) => m.role === "system" || m.role === "developer"),
|
|
532
|
+
tools,
|
|
533
|
+
kind: this.kind(baseUrl) ?? KIND_LLAMACPP,
|
|
534
|
+
capturedAt: Date.now(),
|
|
535
|
+
});
|
|
536
|
+
this.scheduleSave();
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ── Persistencia ──────────────────────────────────────────────────────────
|
|
540
|
+
|
|
541
|
+
private load(): void {
|
|
542
|
+
try {
|
|
543
|
+
if (!fs.existsSync(this.cacheFile)) return;
|
|
544
|
+
const raw = JSON.parse(fs.readFileSync(this.cacheFile, "utf-8")) as {
|
|
545
|
+
templates?: WarmTemplate[];
|
|
546
|
+
};
|
|
547
|
+
const now = Date.now();
|
|
548
|
+
for (const t of raw.templates ?? []) {
|
|
549
|
+
if (!t || typeof t !== "object" || typeof t.model !== "string" || typeof t.baseUrl !== "string") continue;
|
|
550
|
+
if (now - (t.capturedAt ?? 0) > MAX_AGE_MS) continue;
|
|
551
|
+
if (!Array.isArray(t.systemMessages) || t.systemMessages.length === 0) continue;
|
|
552
|
+
this.templates.set(this.key(t.model, t.baseUrl, t.cwd ?? ""), {
|
|
553
|
+
...t,
|
|
554
|
+
kind: t.kind ?? KIND_LLAMACPP,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
// Fichero corrupto → empezar de cero
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
private scheduleSave(): void {
|
|
563
|
+
if (this.saveTimer) return;
|
|
564
|
+
this.saveTimer = setTimeout(() => {
|
|
565
|
+
this.saveTimer = undefined;
|
|
566
|
+
this.save();
|
|
567
|
+
}, SAVE_DEBOUNCE_MS);
|
|
568
|
+
this.saveTimer.unref?.();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private save(): void {
|
|
572
|
+
try {
|
|
573
|
+
const arr = [...this.templates.values()]
|
|
574
|
+
.sort((a, b) => a.capturedAt - b.capturedAt)
|
|
575
|
+
.slice(-MAX_TEMPLATES);
|
|
576
|
+
fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });
|
|
577
|
+
fs.writeFileSync(
|
|
578
|
+
this.cacheFile,
|
|
579
|
+
JSON.stringify({ version: 1, provider: this.provider, templates: arr }),
|
|
580
|
+
"utf-8",
|
|
581
|
+
);
|
|
582
|
+
} catch {
|
|
583
|
+
// Sin permisos / disco lleno → el warmup sigue funcionando en memoria
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
dispose(): void {
|
|
588
|
+
this.disposed = true;
|
|
589
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
590
|
+
for (const ac of this.inFlight.values()) ac.abort();
|
|
591
|
+
this.inFlight.clear();
|
|
592
|
+
this.save();
|
|
593
|
+
}
|
|
594
|
+
}
|