@cyrilmarin/dsh-lemonade 0.2.6 → 0.4.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/README.md +15 -1
- package/lib/adapter.js +5 -4
- package/lib/client.js +200 -8
- package/lib/index.js +8 -1
- package/lib/json-parse.js +230 -0
- package/lib/server-api.js +320 -30
- package/lib/translate.js +67 -7
- package/lib/types/adapter.d.ts +7 -2
- package/lib/types/index.d.ts +1 -0
- package/lib/types/json-parse.d.ts +34 -0
- package/lib/types/server-api.d.ts +4 -10
- package/lib/types/translate.d.ts +19 -4
- package/package.json +2 -2
- package/src/adapter.ts +11 -4
- package/src/client/index.js +200 -8
- package/src/index.ts +9 -0
- package/src/json-parse.ts +175 -0
- package/src/server-api.ts +329 -30
- package/src/translate.ts +71 -5
package/lib/types/translate.d.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import type { StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
2
|
+
/**
|
|
3
|
+
* A `[DONE]` sentinel in the *middle* of a payload stream (i.e. not the final
|
|
4
|
+
* event) is malformed: clean consumers normally emit it only once, at EOF.
|
|
5
|
+
* This is a soft warning — the caller ignores it rather than aborting — so a
|
|
6
|
+
* briefly misbehaving server never silently kills a generation.
|
|
7
|
+
*/
|
|
8
|
+
export declare const MID_STREAM_DONE_WARNING = "lemonade-sse: mid-stream [DONE] detected; the stream may end prematurely";
|
|
2
9
|
/** Parse an SSE byte stream into its `data` payloads. */
|
|
3
|
-
export declare function parseSse(stream: ReadableStream<Uint8Array>, onComment?: (comment: string) => void): AsyncGenerator<string>;
|
|
10
|
+
export declare function parseSse(stream: ReadableStream<Uint8Array>, onComment?: (comment: string) => void, onSkip?: (reason: string) => void): AsyncGenerator<string>;
|
|
4
11
|
/**
|
|
5
12
|
* Consume SSE data payloads (optionally ending with `[DONE]`) and yield
|
|
6
13
|
* harness StreamChunks. Malformed JSON payloads abort the stream with
|
|
7
|
-
* `MALFORMED_RESPONSE
|
|
8
|
-
*
|
|
14
|
+
* `MALFORMED_RESPONSE` — parseSse already skips transiently malformed payloads
|
|
15
|
+
* with a threshold, so a payload reaching this point is genuinely corrupt. A
|
|
16
|
+
* `stop` (or absent) finish with no opened blocks is a degenerate provider
|
|
17
|
+
* completion and maps to an `EMPTY_RESPONSE` error finish.
|
|
18
|
+
*
|
|
19
|
+
* `[DONE]` is skipped (not terminal here): it flows through parseSse and is
|
|
20
|
+
* only treated as a soft warning when a *further* payload follows it — a clean
|
|
21
|
+
* terminal `[DONE]` ends the loop without warning. A mid-stream `[DONE]` (or
|
|
22
|
+
* content after the sentinel) logs a soft warning and the loop continues
|
|
23
|
+
* rather than crashing.
|
|
9
24
|
*/
|
|
10
|
-
export declare function translate(payloads: AsyncIterable<string
|
|
25
|
+
export declare function translate(payloads: AsyncIterable<string>, onSkip?: (reason: string) => void): AsyncGenerator<StreamChunk>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cyrilmarin/dsh-lemonade",
|
|
3
3
|
"description": "Lemonade Server (OpenAI-compatible) LLM provider plugin for the DeepSeek Harness",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"scripts": {
|
|
89
89
|
"build": "tsc -p tsconfig.json && node scripts/copy-client.mjs",
|
|
90
90
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
91
|
-
"test": "node test/adapter.test.mjs && node test/server-api.test.mjs && node test/client-bundle.test.mjs lib/client.js",
|
|
91
|
+
"test": "node test/adapter.test.mjs && node test/server-api.test.mjs && node test/json-parse.test.mjs && node test/translate.test.mjs && node test/client-bundle.test.mjs lib/client.js",
|
|
92
92
|
"release": "node scripts/release.mjs",
|
|
93
93
|
"release:dry": "node scripts/release.mjs --dry-run",
|
|
94
94
|
"release:major": "node scripts/release.mjs --bump major",
|
package/src/adapter.ts
CHANGED
|
@@ -42,8 +42,8 @@ export const DEFAULT_CONTEXT_WINDOW = 32768;
|
|
|
42
42
|
export const DEFAULT_MAX_TOKENS = 8192;
|
|
43
43
|
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
|
44
44
|
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
45
|
-
/**
|
|
46
|
-
export const
|
|
45
|
+
/** Default maximum time one live model-listing query may take. */
|
|
46
|
+
export const DEFAULT_LISTING_TIMEOUT_MS = 5_000;
|
|
47
47
|
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT';
|
|
48
48
|
|
|
49
49
|
/** One entry of the user-pinned advisory model catalog. */
|
|
@@ -66,6 +66,7 @@ export interface LemonadeOptions {
|
|
|
66
66
|
maxTokens: number;
|
|
67
67
|
models: LemonadeCatalogModel[];
|
|
68
68
|
streamIdleTimeoutMs: number;
|
|
69
|
+
listingTimeoutMs: number;
|
|
69
70
|
retryPolicy: ResolvedRetryPolicy;
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -77,6 +78,8 @@ export interface LemonadeAdapterConfig {
|
|
|
77
78
|
resolveApiKey(): Promise<string | undefined>;
|
|
78
79
|
/** The attachment service, when one is mounted (needed to send images). */
|
|
79
80
|
resolveAttachments(): AttachmentStore | undefined;
|
|
81
|
+
/** Optional sink for soft, non-fatal stream warnings (mid-stream `[DONE]`, skipped payloads). */
|
|
82
|
+
logger?: () => { warn(...args: unknown[]): void };
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
/** One Lemonade model entry as read from `GET /v1/models`. */
|
|
@@ -259,7 +262,7 @@ export class LemonadeAdapter extends LlmAdapter {
|
|
|
259
262
|
// No configured selection: advertise whatever the server currently offers.
|
|
260
263
|
try {
|
|
261
264
|
const apiKey = await this.config.resolveApiKey();
|
|
262
|
-
const entries = await fetchModelEntries(options.baseURL, apiKey, AbortSignal.timeout(
|
|
265
|
+
const entries = await fetchModelEntries(options.baseURL, apiKey, AbortSignal.timeout(options.listingTimeoutMs ?? DEFAULT_LISTING_TIMEOUT_MS));
|
|
263
266
|
this.lastKnown = new Map(entries.map((entry) => [entry.id, entry]));
|
|
264
267
|
return entries.map((entry) => modelInfo(provider, entry.id, entry));
|
|
265
268
|
} catch {
|
|
@@ -376,6 +379,10 @@ export class LemonadeAdapter extends LlmAdapter {
|
|
|
376
379
|
});
|
|
377
380
|
}
|
|
378
381
|
if (!response.body) throw new LlmError('Lemonade API returned no response body', 'EMPTY_RESPONSE');
|
|
379
|
-
|
|
382
|
+
const warn = (reason: string): void => { this.config.logger?.().warn(reason); };
|
|
383
|
+
yield* translate(
|
|
384
|
+
parseSse(response.body, onComment, warn),
|
|
385
|
+
warn,
|
|
386
|
+
);
|
|
380
387
|
}
|
|
381
388
|
}
|
package/src/client/index.js
CHANGED
|
@@ -110,6 +110,25 @@ window.__ModuleLoader__.load({
|
|
|
110
110
|
downloadedSuffix: " (downloaded)",
|
|
111
111
|
filesNone: "No local files.",
|
|
112
112
|
missingSuffix: " (missing)",
|
|
113
|
+
logsTitle: "Logs",
|
|
114
|
+
logsTooltip: "Live stream of the Lemonade server logs (WebSocket /logs/stream)",
|
|
115
|
+
logsConnecting: "connecting…",
|
|
116
|
+
logsConnected: "connected",
|
|
117
|
+
logsOffline: "logs unavailable",
|
|
118
|
+
logsAutoScroll: "Auto-scroll",
|
|
119
|
+
logLines: "{count} log line(s) shown",
|
|
120
|
+
logsKeepAlive: "Reconnect",
|
|
121
|
+
logsKeepAliveTitle: "Re-open the log stream",
|
|
122
|
+
logsClosed: "Log stream closed.",
|
|
123
|
+
logsError: "Log stream error.",
|
|
124
|
+
batchHeader: "Batch action",
|
|
125
|
+
batchLoad: "Load selected",
|
|
126
|
+
batchUnload: "Unload selected",
|
|
127
|
+
batchDelete: "Delete selected",
|
|
128
|
+
batchProgress: "Running {done}/{total}",
|
|
129
|
+
batchOk: "Batch done ({failed} failed)",
|
|
130
|
+
modalClose: "Close",
|
|
131
|
+
modalConfirm: "Confirm",
|
|
113
132
|
};
|
|
114
133
|
|
|
115
134
|
/** Lookup + {param} interpolation; English is the default fallback. */
|
|
@@ -126,7 +145,15 @@ window.__ModuleLoader__.load({
|
|
|
126
145
|
}
|
|
127
146
|
const fallbackT = makeT(EN);
|
|
128
147
|
|
|
129
|
-
/** Same-origin call to the host proxy; returns the normalized wire result.
|
|
148
|
+
/** Same-origin call to the host proxy; returns the normalized wire result.
|
|
149
|
+
*
|
|
150
|
+
* Distinguishes three outcomes: a network failure (fetch throws — server
|
|
151
|
+
* unreachable) as code "CLIENT" with status 0, an HTTP error whose body is
|
|
152
|
+
* not the host wire format as "HTTP_<status>", and the host's own wire
|
|
153
|
+
* result (which carries the HTTP `status` and a stable `code`) unchanged.
|
|
154
|
+
* Reading the raw text first (not res.json()) keeps an error status from
|
|
155
|
+
* being swallowed when the body is non-JSON.
|
|
156
|
+
*/
|
|
130
157
|
async function apiCall(op, segments, queryObj, method, bodyObj) {
|
|
131
158
|
let url = API + "/" + op;
|
|
132
159
|
if (segments && segments.length) {
|
|
@@ -146,13 +173,28 @@ window.__ModuleLoader__.load({
|
|
|
146
173
|
init.headers["content-type"] = "application/json";
|
|
147
174
|
init.body = JSON.stringify(bodyObj);
|
|
148
175
|
}
|
|
176
|
+
let res;
|
|
149
177
|
try {
|
|
150
|
-
|
|
151
|
-
const data = await res.json().catch(() => null);
|
|
152
|
-
return data;
|
|
178
|
+
res = await fetch(url, init);
|
|
153
179
|
} catch (e) {
|
|
154
|
-
return { ok: false, error: { message: String((e && e.message) || e), code: "CLIENT" } };
|
|
180
|
+
return { ok: false, error: { message: String((e && e.message) || e), code: "CLIENT", status: 0 } };
|
|
155
181
|
}
|
|
182
|
+
const text = await res.text().catch(() => "");
|
|
183
|
+
let data = null;
|
|
184
|
+
if (text.length > 0) {
|
|
185
|
+
try { data = JSON.parse(text); } catch { data = null; }
|
|
186
|
+
}
|
|
187
|
+
// HTTP error without the host wire shape (non-JSON body, or a bare
|
|
188
|
+
// object): surface the status as a stable "HTTP_<status>" error.
|
|
189
|
+
if (!res.ok && (!data || typeof data !== "object" || !("ok" in data))) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: { message: data && (data.error && data.error.message) ? data.error.message : String((data && data.message) || "HTTP " + res.status), code: "HTTP_" + res.status, status: res.status },
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
// Host wire result ({ ok, error: { message, code, status? } }) — keep
|
|
196
|
+
// the host code and its HTTP status intact so the UI can distinguish.
|
|
197
|
+
return data;
|
|
156
198
|
}
|
|
157
199
|
|
|
158
200
|
const fmt = (value) => (value === undefined || value === null ? "—" : String(value));
|
|
@@ -196,6 +238,84 @@ window.__ModuleLoader__.load({
|
|
|
196
238
|
};
|
|
197
239
|
const el = (type, props, ...children) => h(type, props || {}, ...children);
|
|
198
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Live log pane. Opens an SSE connection to the host proxy's logsStream
|
|
243
|
+
* route (which itself owns a WebSocket client to Lemonade) and appends
|
|
244
|
+
* each upstream `data:` line as a log entry. Holds no internal timer: a
|
|
245
|
+
* reconnect button re-mounts the effect.
|
|
246
|
+
*/
|
|
247
|
+
function LogsStream(props) {
|
|
248
|
+
const t = props.t;
|
|
249
|
+
const [lines, setLines] = useState([]);
|
|
250
|
+
const [status, setStatus] = useState(t("logsConnecting"));
|
|
251
|
+
const [autoScroll, setAutoScroll] = useState(true);
|
|
252
|
+
const [logEl, setLogEl] = useState(null);
|
|
253
|
+
const [retry, setRetry] = useState(0);
|
|
254
|
+
|
|
255
|
+
const append = (text, isError) => {
|
|
256
|
+
setLines((prev) => {
|
|
257
|
+
const next = prev.concat({ text: text, isError: !!isError });
|
|
258
|
+
return next.length > 500 ? next.slice(-500) : next;
|
|
259
|
+
});
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
useEffect(() => {
|
|
263
|
+
const controller = new AbortController();
|
|
264
|
+
setStatus(t("logsConnecting"));
|
|
265
|
+
fetch(API + "/logsStream", { headers: {}, signal: controller.signal })
|
|
266
|
+
.then(async (res) => {
|
|
267
|
+
if (!res.ok) {
|
|
268
|
+
append(t("logsError") + " HTTP " + (res.status || ""), true);
|
|
269
|
+
setStatus(t("logsOffline"));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
setStatus(t("logsConnected"));
|
|
273
|
+
const reader = res.body.getReader();
|
|
274
|
+
const decoder = new TextDecoder();
|
|
275
|
+
let buffer = "";
|
|
276
|
+
const loop = async () => {
|
|
277
|
+
for (;;) {
|
|
278
|
+
const { done, value } = await reader.read();
|
|
279
|
+
if (done) { setStatus(t("logsClosed")); return; }
|
|
280
|
+
buffer += decoder.decode(value, { stream: true });
|
|
281
|
+
let nl;
|
|
282
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
283
|
+
const line = buffer.slice(0, nl).replace(/\r$/, "").trim();
|
|
284
|
+
buffer = buffer.slice(nl + 1);
|
|
285
|
+
if (!line || line.indexOf("event:") === 0) continue;
|
|
286
|
+
if (line.indexOf("data:") === 0) append(line.slice(5).trim(), false);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
void loop();
|
|
291
|
+
})
|
|
292
|
+
.catch(() => { setStatus(t("logsOffline")); });
|
|
293
|
+
return () => controller.abort();
|
|
294
|
+
}, [t, retry]);
|
|
295
|
+
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
if (autoScroll && logEl) logEl.scrollTop = logEl.scrollHeight;
|
|
298
|
+
}, [lines, autoScroll, logEl]);
|
|
299
|
+
|
|
300
|
+
const connected = status.indexOf("connected") >= 0 || status.indexOf("connecting") >= 0;
|
|
301
|
+
|
|
302
|
+
return h("div", { style: styles.card },
|
|
303
|
+
h("div", { style: styles.row, justifyContent: "space-between" },
|
|
304
|
+
h("span", { style: { ...styles.cardTitle, display: "flex", alignItems: "center", gap: "6px" } },
|
|
305
|
+
h("span", { style: { ...styles.badge, ...(connected ? styles.badgeOk : styles.badgeBad) } }, "●"),
|
|
306
|
+
t("logsTitle")),
|
|
307
|
+
h("div", { style: styles.row },
|
|
308
|
+
h("label", { style: { display: "flex", alignItems: "center", gap: "6px", fontSize: "12px" } },
|
|
309
|
+
h("input", { type: "checkbox", checked: autoScroll === true, onChange: (e) => setAutoScroll(e.target.checked) }),
|
|
310
|
+
t("logsAutoScroll")),
|
|
311
|
+
h("button", { style: styles.button, title: t("logsKeepAliveTitle"), onClick: () => setRetry((r) => r + 1) }, t("logsKeepAlive"))),
|
|
312
|
+
),
|
|
313
|
+
h("div", { style: { maxHeight: 360, overflow: "auto", background: "var(--dsw-alias-bg-secondary, #f6f8fa)", borderRadius: 6, padding: "6px 8px", fontFamily: "monospace", fontSize: "12px", whiteSpace: "pre-wrap", wordBreak: "break-word" } },
|
|
314
|
+
h("div", { ref: logEl }, lines.map((l, i) => h("div", { key: i, style: { color: l.isError ? "#d1242f" : "inherit", opacity: l.isError ? 0.9 : 1 } }, l.text)))),
|
|
315
|
+
h("p", { style: styles.muted }, t("logLines", { count: lines.length })),
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
199
319
|
function LemonadeServerView(props) {
|
|
200
320
|
const api = props.api;
|
|
201
321
|
const t = props && typeof props.t === "function" ? props.t : fallbackT;
|
|
@@ -223,6 +343,10 @@ window.__ModuleLoader__.load({
|
|
|
223
343
|
const [aliasTarget, setAliasTarget] = useState("");
|
|
224
344
|
const [adminOpen, setAdminOpen] = useState(true);
|
|
225
345
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
346
|
+
const [selectedModels, setSelectedModels] = useState({});
|
|
347
|
+
const [batchRunning, setBatchRunning] = useState(undefined);
|
|
348
|
+
const [modal, setModal] = useState(undefined);
|
|
349
|
+
const [logsOpen, setLogsOpen] = useState(false);
|
|
226
350
|
|
|
227
351
|
const loadHealth = useCallback(async () => {
|
|
228
352
|
const res = await apiCall("health");
|
|
@@ -255,6 +379,13 @@ window.__ModuleLoader__.load({
|
|
|
255
379
|
setBusy(false);
|
|
256
380
|
}, [loadHealth, loadTelemetry, loadModels, loadDownloads, loadAliases]);
|
|
257
381
|
useEffect(() => { loadAll(); }, [loadAll]);
|
|
382
|
+
// Notices self-dismiss after 5s so a transient "Model loaded" message
|
|
383
|
+
// cannot linger in the pane once the view re-renders for other reasons.
|
|
384
|
+
useEffect(() => {
|
|
385
|
+
if (notice === undefined) return undefined;
|
|
386
|
+
const timer = setTimeout(() => setNotice(undefined), 5000);
|
|
387
|
+
return () => clearTimeout(timer);
|
|
388
|
+
}, [notice]);
|
|
258
389
|
useEffect(() => {
|
|
259
390
|
if (!autoRefresh) return;
|
|
260
391
|
const timer = setInterval(() => { loadHealth(); loadTelemetry(); }, 10000);
|
|
@@ -282,6 +413,34 @@ window.__ModuleLoader__.load({
|
|
|
282
413
|
|
|
283
414
|
const loadedByModel = (id) => (health && Array.isArray(health.all_models_loaded) ? health.all_models_loaded : []).find((m) => m.model_name === id);
|
|
284
415
|
|
|
416
|
+
// Batch multi-select: selectedModels maps model id -> boolean. A batch
|
|
417
|
+
// action dispatches one POST per id through the host proxy and reports
|
|
418
|
+
// how many failed alongside the successes.
|
|
419
|
+
const selectedIds = (Array.isArray(models) ? models : []).filter((m) => (m && selectedModels[m.id] === true)).map((m) => m.id);
|
|
420
|
+
const toggleSelect = (id) => setSelectedModels((prev) => ({ ...prev, [id]: !prev[id] }));
|
|
421
|
+
const runBatch = async (op, label) => {
|
|
422
|
+
if (!selectedIds.length) return;
|
|
423
|
+
setBatchRunning({ op, total: selectedIds.length, done: 0, failed: 0 });
|
|
424
|
+
let failed = 0;
|
|
425
|
+
for (const id of selectedIds) {
|
|
426
|
+
const r = await apiCall(op, [], undefined, "POST", { models: [id] });
|
|
427
|
+
if (!r || !r.ok) failed += 1;
|
|
428
|
+
setBatchRunning((prev) => prev && { ...prev, done: prev.done + 1, failed });
|
|
429
|
+
}
|
|
430
|
+
const done = selectedIds.length - failed;
|
|
431
|
+
setBatchRunning(undefined);
|
|
432
|
+
setSelectedModels({});
|
|
433
|
+
setNotice(t("batchOk", { failed: failed }));
|
|
434
|
+
await loadAll();
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// Custom confirm modal: replaces the native confirm(); the caller
|
|
438
|
+
// passes message/confirmLabel and an onConfirm callback.
|
|
439
|
+
const ask = (message, confirmLabel, onConfirm) => {
|
|
440
|
+
setModal({ message, confirmLabel, onConfirm });
|
|
441
|
+
};
|
|
442
|
+
const closeAsk = () => setModal(undefined);
|
|
443
|
+
|
|
285
444
|
// The tab lists every model the server advertises, optionally filtered
|
|
286
445
|
// to downloaded ones (checkbox in the block header, checked by default).
|
|
287
446
|
// Aliases are hidden: an alias is an entry whose name is in the alias
|
|
@@ -354,6 +513,7 @@ window.__ModuleLoader__.load({
|
|
|
354
513
|
el("span", { style: styles.muted }, health && health.version ? "v" + health.version : ""),
|
|
355
514
|
el("button", { style: styles.button, disabled: busy, onClick: () => loadAll() }, busy ? t("loading") : t("refresh")),
|
|
356
515
|
el("button", { style: styles.button, disabled: busy, onClick: () => setAutoRefresh((v) => !v) }, t(autoRefresh ? "autoOn" : "autoOff")),
|
|
516
|
+
el("button", { style: { ...styles.button, opacity: logsOpen ? 1 : 0.6 }, title: t("logsTooltip"), onClick: () => setLogsOpen((v) => !v) }, "◉ " + t("logsTitle")),
|
|
357
517
|
),
|
|
358
518
|
el("p", { style: styles.muted }, serverURL),
|
|
359
519
|
healthErr && !healthOk ? el("p", { style: styles.error },
|
|
@@ -386,6 +546,10 @@ window.__ModuleLoader__.load({
|
|
|
386
546
|
el("details", { style: { ...styles.card, marginTop: 0 }, open: modelsOpen, onToggle: (e) => setModelsOpen(e.target.open) },
|
|
387
547
|
el("summary", { style: { ...styles.cardTitle, cursor: "pointer" }, title: t("modelsTooltip") }, t("models") + (Array.isArray(visibleModels) ? " (" + visibleModels.length + ")" : "")),
|
|
388
548
|
el("div", { style: styles.row, justifyContent: "flex-end" },
|
|
549
|
+
selectedIds.length ? el("div", { style: { display: "flex", alignItems: "center", gap: "4px", fontSize: "12px", opacity: 0.75 } }, t("batchHeader") + " " + selectedIds.length) : null,
|
|
550
|
+
el("button", { style: { ...styles.button, opacity: selectedIds.length ? 1 : 0.5 }, disabled: busy || !selectedIds.length, onClick: () => runBatch("batchLoad", t("batchLoad")) }, busy && batchRunning && batchRunning.op === "batchLoad" ? t("batchProgress", { done: batchRunning.done, total: batchRunning.total }) : t("batchLoad")),
|
|
551
|
+
el("button", { style: { ...styles.button, opacity: selectedIds.length ? 1 : 0.5 }, disabled: busy || !selectedIds.length, onClick: () => runBatch("batchUnload", t("batchUnload")) }, busy && batchRunning && batchRunning.op === "batchUnload" ? t("batchProgress", { done: batchRunning.done, total: batchRunning.total }) : t("batchUnload")),
|
|
552
|
+
el("button", { style: { ...styles.buttonDanger, opacity: selectedIds.length ? 1 : 0.5 }, disabled: busy || !selectedIds.length, onClick: () => runBatch("batchDelete", t("batchDelete")) }, t("batchDelete")),
|
|
389
553
|
el("label", { style: { display: "flex", alignItems: "center", gap: "6px", fontSize: "13px" } },
|
|
390
554
|
el("input", { type: "checkbox", checked: onlyDownloaded === true, onChange: (e) => setOnlyDownloaded(e.target.checked) }),
|
|
391
555
|
t("onlyDownloaded"),
|
|
@@ -397,6 +561,9 @@ window.__ModuleLoader__.load({
|
|
|
397
561
|
Array.isArray(visibleModels) && visibleModels.length === 0 ? el("p", { style: styles.muted }, t("noModels")) : null,
|
|
398
562
|
Array.isArray(visibleModels) && visibleModels.length > 0 ? el("table", { style: styles.table },
|
|
399
563
|
el("thead", null, el("tr", null,
|
|
564
|
+
el("th", { style: { ...styles.th, width: 40 } },
|
|
565
|
+
(visibleModels.length > 0 && selectedIds.length === visibleModels.length) ? el("input", { type: "checkbox", checked: true, onChange: () => { const all = visibleModels.every((m) => selectedModels[m.id] === true); setSelectedModels(Object.fromEntries(visibleModels.map((m) => [m.id, !all]))); } }) : el("input", { type: "checkbox", onChange: () => { const all = visibleModels.every((m) => selectedModels[m.id] === true); setSelectedModels(Object.fromEntries(visibleModels.map((m) => [m.id, !all]))); } })
|
|
566
|
+
),
|
|
400
567
|
el("th", { style: styles.th }, t("thModel")),
|
|
401
568
|
el("th", { style: styles.th }, t("thRecipe")),
|
|
402
569
|
el("th", { style: styles.th }, t("thSize")),
|
|
@@ -405,7 +572,11 @@ window.__ModuleLoader__.load({
|
|
|
405
572
|
)),
|
|
406
573
|
el("tbody", null, visibleModels.map((m) => {
|
|
407
574
|
const loaded = loadedByModel(m.id);
|
|
408
|
-
|
|
575
|
+
const checked = selectedModels[m.id] === true;
|
|
576
|
+
return el("tr", { key: m.id, style: { background: checked ? "rgba(26,127,55,0.05)" : "transparent" } },
|
|
577
|
+
el("td", { style: styles.td },
|
|
578
|
+
el("input", { type: "checkbox", checked: checked, onChange: () => toggleSelect(m.id) }),
|
|
579
|
+
),
|
|
409
580
|
el("td", { style: styles.td },
|
|
410
581
|
el("span", null, m.id),
|
|
411
582
|
m.update_available ? el("span", { style: { ...styles.chip, borderColor: "#9a6700", color: "#9a6700" } }, t("updateBadge")) : null,
|
|
@@ -422,7 +593,7 @@ window.__ModuleLoader__.load({
|
|
|
422
593
|
: m.downloaded === false ? el("button", { style: styles.button, disabled: busy, onClick: () => run(async () => { const r = await apiCall("pull", [], undefined, "POST", { checkpoint: m.id }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("downloadStarted")) }, t("download"))
|
|
423
594
|
: el("button", { style: styles.button, disabled: busy, onClick: () => run(async () => { const r = await apiCall("load", [], undefined, "POST", { model: m.id }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("modelLoaded")) }, t("load")),
|
|
424
595
|
el("button", { style: styles.button, disabled: busy, onClick: () => toggleFiles(m.id) }, t("files")),
|
|
425
|
-
|
|
596
|
+
el("button", { style: styles.buttonDanger, disabled: busy, onClick: () => ask(t("confirmDeleteModel", { model: m.id }), t("delete"), () => run(async () => { const r = await apiCall("delete", [], undefined, "POST", { model: m.id }); if (!r || !r.ok) throw new Error(errMsg(r)); }, t("modelDeleted"))) }, t("delete")),
|
|
426
597
|
),
|
|
427
598
|
filesById[m.id] !== undefined ? divFiles(filesById[m.id], t) : null,
|
|
428
599
|
),
|
|
@@ -452,7 +623,7 @@ window.__ModuleLoader__.load({
|
|
|
452
623
|
Array.isArray(aliases) && aliases.length > 0 ? el("ul", { style: { margin: "4px 0 0 0", padding: 0, listStyle: "none" } },
|
|
453
624
|
aliases.map((al) => el("li", { key: al.alias, style: { display: "flex", justifyContent: "space-between", alignItems: "center", padding: "4px 0", borderBottom: "1px solid var(--dsw-alias-border-l2, #d0d7de)" } },
|
|
454
625
|
el("span", { style: { fontSize: "13px" } }, String(al.alias) + " → " + String(al.target || al.model || "") + (al.downloaded === true ? t("downloadedSuffix") : "")),
|
|
455
|
-
el("button", { style: styles.buttonDanger, disabled: busy, onClick: () =>
|
|
626
|
+
el("button", { style: styles.buttonDanger, disabled: busy, onClick: () => ask(t("confirmDeleteAlias", { alias: al.alias }), t("delete"), () => run(async () => { const r = await apiCall("internalAliasesDelete", [al.alias]); if (!r || !r.ok) throw new Error(errMsg(r)); setAliases((prev) => Array.isArray(prev) ? prev.filter((x) => x.alias !== al.alias) : prev); }, t("aliasDeleted"))) }, t("delete")),
|
|
456
627
|
))) : null,
|
|
457
628
|
),
|
|
458
629
|
|
|
@@ -466,6 +637,27 @@ window.__ModuleLoader__.load({
|
|
|
466
637
|
|
|
467
638
|
error !== undefined ? el("p", { style: styles.error }, String(error)) : null,
|
|
468
639
|
notice !== undefined ? el("p", { style: styles.success }, String(notice)) : null,
|
|
640
|
+
|
|
641
|
+
logsOpen ? el("div", { style: styles.card }, h(LogsStream, { t })) : null,
|
|
642
|
+
|
|
643
|
+
modal ? h(ConfirmationModal, {
|
|
644
|
+
message: modal.message,
|
|
645
|
+
confirmLabel: modal.confirmLabel,
|
|
646
|
+
onCancel: closeAsk,
|
|
647
|
+
onConfirm: () => { const onConfirm = modal.onConfirm; closeAsk(); if (onConfirm) onConfirm(); },
|
|
648
|
+
}) : null,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Custom confirm modal rendered in place of the native confirm(). */
|
|
653
|
+
function ConfirmationModal(props) {
|
|
654
|
+
const { message, onConfirm, onCancel, confirmLabel } = props;
|
|
655
|
+
return h("div", { style: { position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 9999, fontFamily: "var(--dsw-font-family, sans-serif)" } },
|
|
656
|
+
h("div", { style: { background: "var(--dsw-alias-bg, #fff)", borderRadius: "10px", padding: "18px 20px", minWidth: 260, maxWidth: 420, boxShadow: "0 8px 30px rgba(0,0,0,0.18)" } },
|
|
657
|
+
h("div", { style: { fontSize: "15px", lineHeight: 1.5, marginBottom: "16px" } }, message),
|
|
658
|
+
h("div", { style: { display: "flex", justifyContent: "flex-end", gap: "8px" } },
|
|
659
|
+
h("button", { style: { padding: "6px 14px", fontSize: "13px", borderRadius: "6px", border: "1px solid var(--dsw-alias-border-l2, #d0d7de)", background: "#fff", cursor: "pointer" }, onClick: onCancel }, t("modalClose")),
|
|
660
|
+
h("button", { style: { padding: "6px 14px", fontSize: "13px", borderRadius: "6px", border: "none", background: "#1a7f37", color: "#fff", cursor: "pointer" }, onClick: onConfirm }, confirmLabel || t("modalConfirm")))),
|
|
469
661
|
);
|
|
470
662
|
}
|
|
471
663
|
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
DEFAULT_CONTEXT_WINDOW,
|
|
38
38
|
DEFAULT_MAX_TOKENS,
|
|
39
39
|
DEFAULT_STREAM_IDLE_TIMEOUT_MS,
|
|
40
|
+
DEFAULT_LISTING_TIMEOUT_MS,
|
|
40
41
|
LemonadeAdapter,
|
|
41
42
|
discoverModels,
|
|
42
43
|
} from './adapter.js';
|
|
@@ -82,6 +83,7 @@ export const Config: z<LemonadeResolvedConfig> = z.object({
|
|
|
82
83
|
maxTokens: z.number().step(1).min(1).default(DEFAULT_MAX_TOKENS),
|
|
83
84
|
models: z.array(catalogModel).default([]),
|
|
84
85
|
streamIdleTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS),
|
|
86
|
+
listingTimeoutMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_LISTING_TIMEOUT_MS),
|
|
85
87
|
retryPolicy: RetryPolicySchema,
|
|
86
88
|
});
|
|
87
89
|
|
|
@@ -95,6 +97,7 @@ export interface LemonadeResolvedConfig {
|
|
|
95
97
|
maxTokens: number;
|
|
96
98
|
models: LemonadeCatalogModel[];
|
|
97
99
|
streamIdleTimeoutMs: number;
|
|
100
|
+
listingTimeoutMs: number;
|
|
98
101
|
retryPolicy?: RetryPolicyConfig;
|
|
99
102
|
}
|
|
100
103
|
|
|
@@ -170,6 +173,10 @@ export function resolveAdapterOptions(
|
|
|
170
173
|
if (!Number.isFinite(streamIdleTimeoutMs) || streamIdleTimeoutMs <= 0 || streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
171
174
|
throw new Error(`llm-lemonade: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
172
175
|
}
|
|
176
|
+
const listingTimeoutMs = config.listingTimeoutMs ?? DEFAULT_LISTING_TIMEOUT_MS;
|
|
177
|
+
if (!Number.isFinite(listingTimeoutMs) || listingTimeoutMs <= 0 || listingTimeoutMs > MAX_TIMER_DELAY_MS) {
|
|
178
|
+
throw new Error(`llm-lemonade: listingTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`);
|
|
179
|
+
}
|
|
173
180
|
const rawBase = config.baseURL ?? environment?.get(BASE_URL_ENV)?.value ?? DEFAULT_BASE_URL;
|
|
174
181
|
return {
|
|
175
182
|
apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
|
|
@@ -180,6 +187,7 @@ export function resolveAdapterOptions(
|
|
|
180
187
|
maxTokens: config.maxTokens ?? DEFAULT_MAX_TOKENS,
|
|
181
188
|
models: resolveModels(config.models),
|
|
182
189
|
streamIdleTimeoutMs,
|
|
190
|
+
listingTimeoutMs,
|
|
183
191
|
retryPolicy: resolveRetryPolicy(config.retryPolicy, 'llm-lemonade: retryPolicy'),
|
|
184
192
|
};
|
|
185
193
|
}
|
|
@@ -252,6 +260,7 @@ export function apply(ctx: Context, config: LemonadeRawConfig): void {
|
|
|
252
260
|
options,
|
|
253
261
|
resolveApiKey,
|
|
254
262
|
resolveAttachments: () => ctx.get('attachments'),
|
|
263
|
+
logger: () => ctx.logger,
|
|
255
264
|
});
|
|
256
265
|
|
|
257
266
|
ctx.llm.registerConfigurableProviders([
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Depth-bounded JSON parser for proxied Lemonade request bodies.
|
|
3
|
+
*
|
|
4
|
+
* A hostile client can send a "JSON bomb": a tree whose width is small but
|
|
5
|
+
* whose depth is enormous. A naive `JSON.parse` walks such input with a stack
|
|
6
|
+
* proportional to the depth, which can blow the V8 stack. This parser walks it
|
|
7
|
+
* with an explicit recursion depth cap (`maxDepth`) and rejects deeper input
|
|
8
|
+
* with a {@link JsonParseError} carrying the byte offset of the offending
|
|
9
|
+
* token, so the caller can surface a precise message without the request ever
|
|
10
|
+
* reaching a downstream consumer.
|
|
11
|
+
*
|
|
12
|
+
* Only what the Lemonade proxy needs is supported: objects, arrays, strings
|
|
13
|
+
* (with escapes), numbers, and the `true`/`false`/`null` literals. Whitespace
|
|
14
|
+
* between tokens is skipped. Trailing characters after the value are rejected.
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-lemonade-provider/json-parse
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Error thrown by {@link parseJsonValue} on malformed input or depth overflow. */
|
|
20
|
+
export class JsonParseError extends Error {
|
|
21
|
+
/** Byte offset of the offending token (or where parsing ended). */
|
|
22
|
+
readonly position: number;
|
|
23
|
+
/** True when the cap on nesting depth was exceeded rather than the text being malformed. */
|
|
24
|
+
readonly isDepthOverflow: boolean;
|
|
25
|
+
|
|
26
|
+
constructor(message: string, position: number, isDepthOverflow = false) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'JsonParseError';
|
|
29
|
+
this.position = position;
|
|
30
|
+
this.isDepthOverflow = isDepthOverflow;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse one UTF-8 JSON value from `text`.
|
|
35
|
+
* @param text - the raw request body.
|
|
36
|
+
* @param options - parser options; only `maxDepth` is honoured today.
|
|
37
|
+
* @returns the parsed value (never `undefined`; use `parseJsonValue` for that).
|
|
38
|
+
* @throws {JsonParseError} when the text is not a single valid JSON value or exceeds `maxDepth`.
|
|
39
|
+
*/
|
|
40
|
+
export function parseJsonValue(text: string, options?: { maxDepth?: number }): unknown {
|
|
41
|
+
const maxDepth = options?.maxDepth ?? 64;
|
|
42
|
+
const len = text.length;
|
|
43
|
+
let pos = 0;
|
|
44
|
+
|
|
45
|
+
const skipWhitespace = (): void => {
|
|
46
|
+
while (pos < len) {
|
|
47
|
+
const c = text[pos];
|
|
48
|
+
if (c === ' ' || c === '\t' || c === '\n' || c === '\r') pos++;
|
|
49
|
+
else break;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const fail = (message: string, isDepthOverflow = false): never => {
|
|
54
|
+
throw new JsonParseError(message, pos, isDepthOverflow);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const parseValue = (depth: number): unknown => {
|
|
58
|
+
skipWhitespace();
|
|
59
|
+
const c = text[pos] ?? '';
|
|
60
|
+
if (pos >= len) fail('unexpected end of input');
|
|
61
|
+
if (c === '{') return parseObject(depth + 1);
|
|
62
|
+
if (c === '[') return parseArray(depth + 1);
|
|
63
|
+
if (c === '"') return parseString();
|
|
64
|
+
if (c === 't' || c === 'f') return parseBooleanLiteral();
|
|
65
|
+
if (c === 'n') return parseNullLiteral();
|
|
66
|
+
if (c === '-' || (c >= '0' && c <= '9')) return parseNumber();
|
|
67
|
+
fail('unexpected character "' + c + '"');
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const parseObject = (depth: number): Record<string, unknown> => {
|
|
71
|
+
if (depth > maxDepth) fail('request body exceeds JSON depth ' + maxDepth, true);
|
|
72
|
+
const out: Record<string, unknown> = {};
|
|
73
|
+
pos++; // consume '{'
|
|
74
|
+
skipWhitespace();
|
|
75
|
+
if (text[pos] === '}') { pos++; return out; }
|
|
76
|
+
for (;;) {
|
|
77
|
+
skipWhitespace();
|
|
78
|
+
if (text[pos] !== '"') fail('expected object key string');
|
|
79
|
+
const key = parseString();
|
|
80
|
+
skipWhitespace();
|
|
81
|
+
if (text[pos] !== ':') fail('expected ":" after key');
|
|
82
|
+
pos++;
|
|
83
|
+
out[key] = parseValue(depth);
|
|
84
|
+
skipWhitespace();
|
|
85
|
+
const close = text[pos];
|
|
86
|
+
if (close === ',') { pos++; continue; }
|
|
87
|
+
if (close === '}') { pos++; return out; }
|
|
88
|
+
fail('expected "," or "}" in object');
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const parseArray = (depth: number): unknown[] => {
|
|
93
|
+
if (depth > maxDepth) fail('request body exceeds JSON depth ' + maxDepth, true);
|
|
94
|
+
const out: unknown[] = [];
|
|
95
|
+
pos++; // consume '['
|
|
96
|
+
skipWhitespace();
|
|
97
|
+
if (text[pos] === ']') { pos++; return out; }
|
|
98
|
+
for (;;) {
|
|
99
|
+
out.push(parseValue(depth));
|
|
100
|
+
skipWhitespace();
|
|
101
|
+
const close = text[pos];
|
|
102
|
+
if (close === ',') { pos++; continue; }
|
|
103
|
+
if (close === ']') { pos++; return out; }
|
|
104
|
+
fail('expected "," or "]" in array');
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const parseString = (): string => {
|
|
109
|
+
pos++; // consume opening '"'
|
|
110
|
+
let out = '';
|
|
111
|
+
for (;;) {
|
|
112
|
+
if (pos >= len) fail('unterminated string');
|
|
113
|
+
const c = text[pos++];
|
|
114
|
+
if (c === '"') return out;
|
|
115
|
+
if (c === '\\') {
|
|
116
|
+
if (pos >= len) fail('unterminated escape');
|
|
117
|
+
const e = text[pos++];
|
|
118
|
+
switch (e) {
|
|
119
|
+
case '"': out += '"'; break;
|
|
120
|
+
case '\\': out += '\\'; break;
|
|
121
|
+
case '/': out += '/'; break;
|
|
122
|
+
case 'b': out += '\b'; break;
|
|
123
|
+
case 'f': out += '\f'; break;
|
|
124
|
+
case 'n': out += '\n'; break;
|
|
125
|
+
case 'r': out += '\r'; break;
|
|
126
|
+
case 't': out += '\t'; break;
|
|
127
|
+
case 'u': {
|
|
128
|
+
const hex = text.slice(pos, pos + 4);
|
|
129
|
+
if (hex.length < 4 || !/^[0-9a-fA-F]{4}$/.test(hex)) fail('invalid unicode escape');
|
|
130
|
+
out += String.fromCharCode(parseInt(hex, 16));
|
|
131
|
+
pos += 4;
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
default: fail('invalid escape \\' + e);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
out += c;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const parseBooleanLiteral = (): boolean => {
|
|
143
|
+
if (text.startsWith('true', pos)) { pos += 4; return true; }
|
|
144
|
+
if (text.startsWith('false', pos)) { pos += 5; return false; }
|
|
145
|
+
throw fail('invalid literal');
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const parseNullLiteral = (): null => {
|
|
149
|
+
if (text.startsWith('null', pos)) { pos += 4; return null; }
|
|
150
|
+
throw fail('invalid literal');
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const parseNumber = (): number => {
|
|
154
|
+
const start = pos;
|
|
155
|
+
if (text[pos] === undefined || text[pos] === '-') {
|
|
156
|
+
if (text[pos] === '-') pos++;
|
|
157
|
+
}
|
|
158
|
+
while (pos < len) {
|
|
159
|
+
const c = text[pos];
|
|
160
|
+
if (c === undefined) break;
|
|
161
|
+
if ((c >= '0' && c <= '9') || c === '.' || c === 'e' || c === 'E' || c === '+' || c === '-') pos++;
|
|
162
|
+
else break;
|
|
163
|
+
}
|
|
164
|
+
const numText = text.slice(start, pos);
|
|
165
|
+
const num = Number(numText);
|
|
166
|
+
if (!Number.isFinite(num)) fail('invalid number "' + numText + '"');
|
|
167
|
+
return num;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
skipWhitespace();
|
|
171
|
+
const value = parseValue(0);
|
|
172
|
+
skipWhitespace();
|
|
173
|
+
if (pos !== len) fail('trailing characters after JSON value');
|
|
174
|
+
return value;
|
|
175
|
+
}
|