@cyrilmarin/dsh-lemonade 0.2.1

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,356 @@
1
+ import { attributionHeaders } from '@deepseek-ai/dsh-llm';
2
+ import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout';
3
+ /** Route prefix registered on ctx.webServer. */
4
+ export const API_ROUTE = '/dsh-lemonade/api';
5
+ /** Maximum accepted request body and proxied response body in bytes. */
6
+ export const MAX_BODY_BYTES = 1_000_000;
7
+ /** Fetch timeout for proxied Lemonade calls. */
8
+ export const API_TIMEOUT_MS = 10_000;
9
+ const TIMEOUT_CODE = 'LEMONADE_API_TIMEOUT';
10
+ const okResult = (value) => ({ ok: true, value });
11
+ const errResult = (message, code, status) => ({
12
+ ok: false,
13
+ error: { message, code, ...(status === undefined ? {} : { status }) },
14
+ });
15
+ /** Map a Lemonade HTTP status to a stable harness-style code. */
16
+ export function mapLemonadeStatus(status) {
17
+ if (status === 401 || status === 403)
18
+ return 'AUTH';
19
+ if (status === 429)
20
+ return 'RATE_LIMIT';
21
+ if (status === 409)
22
+ return 'CONFLICT';
23
+ if (status === 400)
24
+ return 'INVALID_REQUEST';
25
+ if (status >= 500)
26
+ return 'SERVER';
27
+ return 'HTTP_' + status;
28
+ }
29
+ /** Client-input error normalized to a wire result. */
30
+ /** Ops that target internal/control endpoints, authenticated with the admin key. */
31
+ const ADMIN_OPS = new Set([
32
+ 'metrics',
33
+ 'internalTelemetryFlush',
34
+ 'internalAliases',
35
+ 'internalAliasesSet',
36
+ 'internalAliasesDelete',
37
+ ]);
38
+ const isAdminOp = (op) => ADMIN_OPS.has(op);
39
+ class RequestError extends Error {
40
+ code;
41
+ status;
42
+ constructor(message, code, status) {
43
+ super(message);
44
+ this.code = code;
45
+ this.status = status;
46
+ }
47
+ }
48
+ function asRecord(value) {
49
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
50
+ ? value
51
+ : {};
52
+ }
53
+ /** Build the stable Lemonade endpoint for one op. */
54
+ function resolveTarget(op, args, query, body) {
55
+ const record = asRecord(body);
56
+ const pick = (keys) => {
57
+ const out = {};
58
+ for (const key of keys)
59
+ if (record[key] !== undefined)
60
+ out[key] = record[key];
61
+ return out;
62
+ };
63
+ const qs = (extra) => {
64
+ const params = new URLSearchParams();
65
+ for (const [key, value] of Object.entries(extra)) {
66
+ if (value !== undefined && value.length > 0)
67
+ params.set(key, value);
68
+ }
69
+ const text = params.toString();
70
+ return text.length > 0 ? '?' + text : '';
71
+ };
72
+ const str = (value) => (typeof value === 'string' && value.length > 0 ? value : undefined);
73
+ /**
74
+ * The Lemonade model-management endpoints take the model id under
75
+ * `model_name` (the spec: load/unload/delete). This client historically sent
76
+ * `model`; accept both and forward the canonical `model_name`.
77
+ */
78
+ const modelName = () => {
79
+ const named = record.model_name;
80
+ if (typeof named === 'string' && named.length > 0)
81
+ return named;
82
+ const alias = record.model;
83
+ return typeof alias === 'string' && alias.length > 0 ? alias : undefined;
84
+ };
85
+ switch (op) {
86
+ case 'health': return { method: 'GET', url: '/v1/health' };
87
+ case 'live': return { method: 'GET', url: '/live' };
88
+ case 'models':
89
+ return { method: 'GET', url: '/v1/models' + qs({ show_all: str(query.get('show_all') ?? record.show_all) }) };
90
+ case 'modelFiles': {
91
+ const id = args[0];
92
+ if (id === undefined || id.length === 0)
93
+ throw new RequestError('model id required', 'INVALID_REQUEST', 400);
94
+ return { method: 'GET', url: '/v1/models/' + encodeURIComponent(id) + '/files' };
95
+ }
96
+ case 'load': {
97
+ const mn = modelName();
98
+ if (mn === undefined)
99
+ throw new RequestError('model required', 'INVALID_REQUEST', 400);
100
+ return {
101
+ method: 'POST',
102
+ url: '/v1/load',
103
+ body: {
104
+ model_name: mn,
105
+ ...pick(['pinned', 'save_options', 'ctx_size', 'llamacpp_backend', 'llamacpp_args', 'whispercpp_backend', 'whispercpp_args', 'steps', 'cfg_scale', 'width', 'height', 'merge_args']),
106
+ },
107
+ };
108
+ }
109
+ case 'unload': {
110
+ const mn = modelName();
111
+ // model_name optional: omitted unloads every loaded model (spec).
112
+ return { method: 'POST', url: '/v1/unload', body: mn === undefined ? {} : { model_name: mn } };
113
+ }
114
+ case 'delete': {
115
+ const mn = modelName();
116
+ if (mn === undefined)
117
+ throw new RequestError('model required', 'INVALID_REQUEST', 400);
118
+ return { method: 'POST', url: '/v1/delete', body: { model_name: mn } };
119
+ }
120
+ case 'checkUpdates': return { method: 'POST', url: '/v1/models/check-updates' };
121
+ case 'registrySearch':
122
+ return {
123
+ method: 'GET',
124
+ url: '/v1/registry/search' + qs({
125
+ query: str(query.get('query') ?? query.get('q') ?? record.query),
126
+ source: str(query.get('source') ?? record.source),
127
+ format: str(query.get('format') ?? record.format),
128
+ limit: str(query.get('limit') ?? (record.limit !== undefined ? String(record.limit) : undefined)),
129
+ }),
130
+ };
131
+ case 'pullVariants':
132
+ return {
133
+ method: 'GET',
134
+ url: '/v1/pull/variants' + qs({
135
+ checkpoint: str(query.get('checkpoint') ?? record.checkpoint),
136
+ }),
137
+ };
138
+ case 'pull':
139
+ // Spec: model_name (Yes, user.* namespace), recipe (Yes), checkpoint (Yes*),
140
+ // plus checkpoints dict, reasoning/vision/embedding/reranking, mmproj.
141
+ return {
142
+ method: 'POST',
143
+ url: '/v1/pull',
144
+ body: pick(['model_name', 'recipe', 'checkpoint', 'checkpoints', 'reasoning', 'vision', 'embedding', 'reranking', 'mmproj', 'stream', 'subscribe']),
145
+ };
146
+ case 'downloads': return { method: 'GET', url: '/v1/downloads' };
147
+ case 'downloadsControl': return { method: 'POST', url: '/v1/downloads/control', body: pick(['id', 'action']) };
148
+ case 'stats': return { method: 'GET', url: '/v1/stats' };
149
+ case 'systemStats': return { method: 'GET', url: '/v1/system-stats' };
150
+ case 'systemInfo': return { method: 'GET', url: '/v1/system-info' };
151
+ case 'cloudAuthSet': return { method: 'POST', url: '/v1/cloud/auth', body: pick(['provider', 'api_key']) };
152
+ case 'cloudAuthDelete': {
153
+ const provider = args[0];
154
+ if (provider === undefined || provider.length === 0)
155
+ throw new RequestError('provider required', 'INVALID_REQUEST', 400);
156
+ return { method: 'DELETE', url: '/v1/cloud/auth/' + encodeURIComponent(provider) };
157
+ }
158
+ // Admin / internal endpoints (authenticated with the admin API key).
159
+ case 'metrics':
160
+ return { method: 'GET', url: '/metrics' };
161
+ case 'internalTelemetryFlush':
162
+ return { method: 'POST', url: '/internal/telemetry/flush' };
163
+ case 'internalAliases':
164
+ return { method: 'GET', url: '/internal/aliases' };
165
+ case 'internalAliasesSet':
166
+ return { method: 'POST', url: '/internal/aliases', body: pick(['alias', 'target', 'model']) };
167
+ case 'internalAliasesDelete': {
168
+ const alias = args[0];
169
+ if (alias === undefined || alias.length === 0)
170
+ throw new RequestError('alias required', 'INVALID_REQUEST', 400);
171
+ return { method: 'DELETE', url: '/internal/aliases/' + encodeURIComponent(alias) };
172
+ }
173
+ default: throw new RequestError('unknown Lemonade api op: ' + op, 'NOT_FOUND', 404);
174
+ }
175
+ }
176
+ async function readResponseText(response) {
177
+ const buffer = await response.arrayBuffer();
178
+ if (buffer.byteLength > MAX_BODY_BYTES)
179
+ return { text: '', tooLarge: true };
180
+ return { text: new TextDecoder().decode(buffer), tooLarge: false };
181
+ }
182
+ /**
183
+ * Dispatch one proxied Lemonade API call.
184
+ * @param cfg - connection facts (thunks resolved per call).
185
+ * @param method - HTTP method from the client (GET/POST/DELETE).
186
+ * @param op - first path segment after the route prefix.
187
+ * @param segments - remaining path segments after the route prefix.
188
+ * @param query - parsed query string.
189
+ * @param body - parsed request body (undefined when none).
190
+ * @param signal - optional caller cancellation.
191
+ */
192
+ export async function serveLemonadeApi(cfg, method, op, args, query, body, signal) {
193
+ let target;
194
+ try {
195
+ target = resolveTarget(op, args, query, body);
196
+ }
197
+ catch (error) {
198
+ if (error instanceof RequestError)
199
+ return errResult(error.message, error.code, error.status);
200
+ throw error;
201
+ }
202
+ if (method !== target.method) {
203
+ return errResult('method ' + method + ' not allowed for ' + op + ' (expected ' + target.method + ')', 'METHOD_NOT_ALLOWED', 405);
204
+ }
205
+ // Per-endpoint key selection: internal/control endpoints (/internal/*, /metrics)
206
+ // authenticate with the admin key (falling back to the regular key, which
207
+ // lemonade accepts for /metrics); regular endpoints use the regular key (the
208
+ // admin key is a superior credential and also works when it is the only one set).
209
+ const admin = isAdminOp(op);
210
+ const regularKey = await cfg.resolveKey(cfg.apiKeyRef());
211
+ const adminKey = await cfg.resolveKey(cfg.adminApiKeyRef());
212
+ const apiKey = admin ? (adminKey ?? regularKey) : (regularKey ?? adminKey);
213
+ if (!admin && apiKey === undefined && cfg.requireAuth()) {
214
+ return errResult('llm-lemonade: the Lemonade server requires a key (requireAuth) and none is set; store LEMONADE_API_KEY or LEMONADE_ADMIN_API_KEY via Settings > Models > Lemonade', 'MISSING_CREDENTIAL');
215
+ }
216
+ const configured = (cfg.baseURL() || '').replace(/\/+$/, '').replace(/\/v1$/i, '');
217
+ // /internal/*, /live and /metrics are ROOT-level (no /api, no /v1) per spec;
218
+ // everything else is served under the /api prefix.
219
+ const isRootPath = target.url.startsWith('/internal/') || target.url === '/live' || target.url === '/metrics';
220
+ const base = isRootPath ? configured.replace(/\/api$/i, '') : configured;
221
+ const url = base + target.url;
222
+ const headers = { accept: 'application/json', ...attributionHeaders() };
223
+ if (apiKey !== undefined)
224
+ headers.authorization = 'Bearer ' + apiKey;
225
+ if (target.body !== undefined)
226
+ headers['content-type'] = 'application/json';
227
+ const timer = deadline(signal, API_TIMEOUT_MS, TIMEOUT_CODE);
228
+ let response;
229
+ try {
230
+ response = await fetch(url, {
231
+ method: target.method,
232
+ headers,
233
+ ...(target.body === undefined ? {} : { body: JSON.stringify(target.body) }),
234
+ signal: timer.signal,
235
+ });
236
+ }
237
+ catch (error) {
238
+ if (timeoutOf(timer.signal, TIMEOUT_CODE) !== undefined) {
239
+ return errResult('Lemonade API timeout after ' + API_TIMEOUT_MS + 'ms', 'TIMEOUT');
240
+ }
241
+ if (signal !== undefined && signal.aborted)
242
+ return errResult('Lemonade request aborted by caller', 'ABORTED');
243
+ return errResult('could not reach ' + url, 'TRANSPORT');
244
+ }
245
+ finally {
246
+ timer[Symbol.dispose]();
247
+ }
248
+ const decoded = await readResponseText(response);
249
+ if (decoded.tooLarge) {
250
+ return errResult('Lemonade response exceeds ' + MAX_BODY_BYTES + ' bytes', 'PAYLOAD_TOO_LARGE', 413);
251
+ }
252
+ let value = null;
253
+ if (decoded.text.length > 0) {
254
+ if (op === 'metrics') {
255
+ // Prometheus text exposition format, not JSON.
256
+ value = decoded.text;
257
+ }
258
+ else {
259
+ try {
260
+ value = JSON.parse(decoded.text);
261
+ }
262
+ catch {
263
+ return errResult('Lemonade answered with non-JSON at ' + url, 'BAD_RESPONSE', 502);
264
+ }
265
+ }
266
+ }
267
+ if (!response.ok) {
268
+ let message = 'Lemonade API error (HTTP ' + response.status + ')';
269
+ if (value !== null && typeof value === 'object') {
270
+ const record = asRecord(value);
271
+ const nested = record.error !== null && typeof record.error === 'object' ? asRecord(record.error) : undefined;
272
+ if (nested && typeof nested.message === 'string' && nested.message.length > 0)
273
+ message = nested.message;
274
+ else if (typeof record.message === 'string' && record.message.length > 0)
275
+ message = record.message;
276
+ }
277
+ return errResult(message, mapLemonadeStatus(response.status), response.status);
278
+ }
279
+ // Lemonade write endpoints (load/unload/delete/pull) answer HTTP 200 with
280
+ // { status: 'error', message } on failure; surface it as a wire error.
281
+ if (value !== null && typeof value === 'object') {
282
+ const record = value;
283
+ if (record.status === 'error' && typeof record.message === 'string' && record.message.length > 0) {
284
+ return errResult(record.message, 'LEMONADE_ERROR', response.status);
285
+ }
286
+ }
287
+ return okResult(value);
288
+ }
289
+ async function readRequestBody(req) {
290
+ const chunks = [];
291
+ let size = 0;
292
+ for await (const chunk of req) {
293
+ size += chunk.length;
294
+ if (size > MAX_BODY_BYTES)
295
+ throw new RequestError('request body exceeds ' + MAX_BODY_BYTES + ' bytes', 'PAYLOAD_TOO_LARGE', 413);
296
+ chunks.push(chunk);
297
+ }
298
+ if (chunks.length === 0)
299
+ return undefined;
300
+ const text = Buffer.concat(chunks).toString('utf8');
301
+ if (text.trim().length === 0)
302
+ return undefined;
303
+ try {
304
+ return JSON.parse(text);
305
+ }
306
+ catch {
307
+ throw new RequestError('request body is not valid JSON', 'INVALID_REQUEST', 400);
308
+ }
309
+ }
310
+ function writeJson(res, status, value) {
311
+ res.writeHead(status, {
312
+ 'content-type': 'application/json; charset=utf-8',
313
+ 'cache-control': 'no-store',
314
+ });
315
+ res.end(JSON.stringify(value));
316
+ }
317
+ /**
318
+ * Stream the Lemonade server logs (WS /logs/stream) as newline-delimited JSON
319
+ * to the browser. The spec: the log WebSocket shares the Realtime Audio port,
320
+ * discovered via /v1/health (websocket_port) — not the main HTTP port — then
321
+ * `ws://<host>:<port>/logs/stream`, subscribe with `{ type: 'logs.subscribe',
322
+ * after_seq: <int|null> }`, and the server answers `logs.snapshot` (up to
323
+ * 5000 retained entries) then `logs.entry` lines. Messages are relayed as-is
324
+ * (`{ type: 'logs.snapshot' | 'logs.entry' | 'error', ... }`); the response is
325
+ * held open and closed when the browser disconnects.
326
+ */
327
+ /**
328
+ * Build the node:http handler mounting the Lemonade-specific API proxy at the
329
+ * /dsh-lemonade/api prefix route (ctx.webServer.register). Never throws out:
330
+ * every outcome is normalized to a JSON wire result.
331
+ */
332
+ export function createLemonadeApiHandler(cfg) {
333
+ return async (req, res) => {
334
+ let result;
335
+ try {
336
+ const url = new URL(req.url ?? '/', 'http://localhost');
337
+ const rest = url.pathname.startsWith(API_ROUTE) ? url.pathname.slice(API_ROUTE.length) : url.pathname;
338
+ const segments = rest.split('/').filter((part) => part.length > 0);
339
+ const op = segments[0] ?? '';
340
+ const args = segments.slice(1);
341
+ const body = req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH'
342
+ ? await readRequestBody(req)
343
+ : undefined;
344
+ result = await serveLemonadeApi(cfg, req.method ?? 'GET', op, args, url.searchParams, body);
345
+ }
346
+ catch (error) {
347
+ result =
348
+ error instanceof RequestError
349
+ ? errResult(error.message, error.code, error.status)
350
+ : errResult('Lemonade API proxy failed: ' + String(error?.message ?? error), 'SERVER', 500);
351
+ }
352
+ const status = result.ok ? 200 : (result.error.status ?? 500);
353
+ writeJson(res, status, result);
354
+ };
355
+ }
356
+ //# sourceMappingURL=server-api.js.map
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Translate Lemonade's OpenAI-compatible SSE payloads into the harness
3
+ * `StreamChunk` protocol.
4
+ *
5
+ * One stateful block is kept per content, reasoning, or tool-call index; an
6
+ * empty initial delta does not open a block. Block-ends, usage, and finish are
7
+ * deferred to the terminal emission at the end of the payload stream, so no
8
+ * chunk ever follows `finish`.
9
+ *
10
+ * Lemonade's docs do not guarantee the `[DONE]` sentinel, so a clean EOF
11
+ * (the payload iterator simply ends) is treated as a normal completion, unlike
12
+ * providers that promise the sentinel. `[DONE]`, when present, is consumed
13
+ * and skipped.
14
+ *
15
+ * @module dsh-lemonade-provider/translate
16
+ */
17
+ import { CallId, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm';
18
+ import { EventSourceParserStream } from 'eventsource-parser/stream';
19
+ /** Parse an SSE byte stream into its `data` payloads. */
20
+ export async function* parseSse(stream, onComment) {
21
+ const events = stream
22
+ .pipeThrough(new TextDecoderStream())
23
+ .pipeThrough(new EventSourceParserStream({ onComment }));
24
+ for await (const { data } of events) {
25
+ yield data;
26
+ if (data === '[DONE]')
27
+ return;
28
+ }
29
+ }
30
+ /**
31
+ * Map the wire `finish_reason` vocabulary to the harness FinishReason.
32
+ * Unrecognized values (content_filter, …) become a severity-typed error
33
+ * finish with the uppercased value as the code.
34
+ */
35
+ function mapFinishReason(reason) {
36
+ switch (reason) {
37
+ case 'stop': return { kind: 'stop' };
38
+ case 'tool_calls': return { kind: 'tool-calls' };
39
+ case 'length': return { kind: 'max-tokens' };
40
+ default: return { kind: 'error', failure: { message: `model stopped: ${reason}`, code: reason.toUpperCase() } };
41
+ }
42
+ }
43
+ /**
44
+ * Map wire usage fields. OpenAI's `prompt_tokens` is a TOTAL that includes
45
+ * cache hits (`prompt_tokens_details.cached_tokens`); the harness TokenUsage
46
+ * convention is DISJOINT counts, so cache reads are subtracted out.
47
+ */
48
+ function mapUsage(usage) {
49
+ const details = usage['prompt_tokens_details'];
50
+ const completionDetails = usage['completion_tokens_details'];
51
+ const cacheRead = typeof details?.['cached_tokens'] === 'number' ? details['cached_tokens'] : undefined;
52
+ const reasoning = typeof completionDetails?.['reasoning_tokens'] === 'number' ? completionDetails['reasoning_tokens'] : undefined;
53
+ return {
54
+ inputTokens: usage['prompt_tokens'] - (cacheRead ?? 0),
55
+ outputTokens: usage['completion_tokens'],
56
+ ...(cacheRead !== undefined ? { cacheReadTokens: cacheRead } : {}),
57
+ ...(reasoning !== undefined ? { reasoningTokens: reasoning } : {}),
58
+ };
59
+ }
60
+ /** Assemble the final ContentBlock for one open block. */
61
+ function closeBlock(block) {
62
+ switch (block.kind) {
63
+ case 'text': return { type: 'text', text: block.text };
64
+ case 'reasoning': return { type: 'reasoning', text: block.text };
65
+ case 'tool-call': return {
66
+ type: 'tool-call',
67
+ id: CallId(block.callId ?? ''),
68
+ name: block.name ?? '',
69
+ arguments: block.text,
70
+ };
71
+ }
72
+ }
73
+ /**
74
+ * Consume SSE data payloads (optionally ending with `[DONE]`) and yield
75
+ * harness StreamChunks. Malformed JSON payloads abort the stream with
76
+ * `MALFORMED_RESPONSE`. A `stop` (or absent) finish with no opened blocks is a
77
+ * degenerate provider completion and maps to an `EMPTY_RESPONSE` error finish.
78
+ */
79
+ export async function* translate(payloads) {
80
+ let nextIndex = 0;
81
+ let textBlock;
82
+ let reasoningBlock;
83
+ const toolBlocks = new Map();
84
+ const order = [];
85
+ let pendingFinish;
86
+ let pendingUsage;
87
+ function open(kind) {
88
+ const block = { index: nextIndex++, kind, text: '' };
89
+ order.push(block);
90
+ return block;
91
+ }
92
+ for await (const payload of payloads) {
93
+ if (payload === '[DONE]')
94
+ continue;
95
+ let chunk;
96
+ try {
97
+ chunk = JSON.parse(payload);
98
+ }
99
+ catch {
100
+ throw new LlmError(`malformed SSE payload: ${payload.slice(0, 120)}`, 'MALFORMED_RESPONSE');
101
+ }
102
+ for (const choice of chunk.choices ?? []) {
103
+ const delta = choice.delta ?? {};
104
+ const reasoning = delta.reasoning_content;
105
+ if (typeof reasoning === 'string' && reasoning.length > 0) {
106
+ if (!reasoningBlock) {
107
+ reasoningBlock = open('reasoning');
108
+ yield { type: 'block-start', index: reasoningBlock.index, blockType: 'reasoning' };
109
+ }
110
+ reasoningBlock.text += reasoning;
111
+ yield { type: 'reasoning-delta', index: reasoningBlock.index, text: reasoning };
112
+ }
113
+ const content = delta.content;
114
+ if (typeof content === 'string' && content.length > 0) {
115
+ if (!textBlock) {
116
+ textBlock = open('text');
117
+ yield { type: 'block-start', index: textBlock.index, blockType: 'text' };
118
+ }
119
+ textBlock.text += content;
120
+ yield { type: 'text-delta', index: textBlock.index, text: content };
121
+ }
122
+ for (const call of delta.tool_calls ?? []) {
123
+ const callIndex = call.index ?? toolBlocks.size;
124
+ let block = toolBlocks.get(callIndex);
125
+ if (!block) {
126
+ block = open('tool-call');
127
+ toolBlocks.set(callIndex, block);
128
+ yield { type: 'block-start', index: block.index, blockType: 'tool-call' };
129
+ }
130
+ if (typeof call.id === 'string')
131
+ block.callId = call.id;
132
+ if (typeof call.function?.name === 'string')
133
+ block.name = call.function.name;
134
+ const fragment = typeof call.function?.arguments === 'string' ? call.function.arguments : '';
135
+ block.text += fragment;
136
+ yield {
137
+ type: 'tool-call-delta',
138
+ index: block.index,
139
+ id: CallId(block.callId ?? ''),
140
+ ...(block.name !== undefined ? { name: block.name } : {}),
141
+ argumentsDelta: fragment,
142
+ };
143
+ }
144
+ if (typeof choice.finish_reason === 'string')
145
+ pendingFinish = mapFinishReason(choice.finish_reason);
146
+ }
147
+ if (chunk.usage)
148
+ pendingUsage = mapUsage(chunk.usage);
149
+ }
150
+ // Terminal emission: nothing follows the finish chunk.
151
+ for (const block of order)
152
+ yield { type: 'block-end', index: block.index, block: closeBlock(block) };
153
+ if (pendingUsage)
154
+ yield { type: 'usage', usage: pendingUsage };
155
+ const reason = pendingFinish ?? { kind: 'stop' };
156
+ yield {
157
+ type: 'finish',
158
+ reason: reason.kind === 'stop' && order.length === 0
159
+ ? { kind: 'error', failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE } }
160
+ : reason,
161
+ };
162
+ }
163
+ //# sourceMappingURL=translate.js.map
@@ -0,0 +1,94 @@
1
+ /**
2
+ * `LemonadeAdapter`: fetch + SSE against a Lemonade Server (OpenAI-compatible)
3
+ * chat-completions endpoint, emitting harness StreamChunks.
4
+ *
5
+ * The adapter is transport-only: connection facts arrive through a thunk
6
+ * resolved once per operation and the optional bearer token through a
7
+ * per-request resolver, so the registering plugin owns validation, layering,
8
+ * and credential policy.
9
+ *
10
+ * @module dsh-lemonade-provider/adapter
11
+ */
12
+ import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
13
+ import type { CredentialRef } from '@deepseek-ai/dsh-credentials';
14
+ import { LlmAdapter } from '@deepseek-ai/dsh-llm';
15
+ import type { GenerateOptions, LlmDiscoveredModel, LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm';
16
+ /** Default endpoint (baseURL is the server root; /v1 paths are appended by each endpoint builder). */
17
+ export declare const DEFAULT_BASE_URL = "http://localhost:13305";
18
+ /** Default combined request/response context capacity for models with no metadata. */
19
+ export declare const DEFAULT_CONTEXT_WINDOW = 32768;
20
+ /** Default per-request output-token cap. */
21
+ export declare const DEFAULT_MAX_TOKENS = 8192;
22
+ /** Default maximum idle interval while an adapter stream read is outstanding. */
23
+ export declare const DEFAULT_STREAM_IDLE_TIMEOUT_MS: number;
24
+ /** Maximum time one live model-listing query may take. */
25
+ export declare const LISTING_TIMEOUT_MS = 5000;
26
+ /** One entry of the user-pinned advisory model catalog. */
27
+ export interface LemonadeCatalogModel {
28
+ id: string;
29
+ name?: string;
30
+ description?: string;
31
+ contextWindow?: number;
32
+ maxTokens?: number;
33
+ vision?: boolean;
34
+ }
35
+ /** Validated connection facts resolved from raw config and the environment. */
36
+ export interface LemonadeOptions {
37
+ apiKeyEnv: CredentialRef;
38
+ adminApiKeyEnv: CredentialRef;
39
+ baseURL: string;
40
+ requireAuth: boolean;
41
+ defaultContextWindow: number;
42
+ maxTokens: number;
43
+ models: LemonadeCatalogModel[];
44
+ streamIdleTimeoutMs: number;
45
+ retryPolicy: ResolvedRetryPolicy;
46
+ }
47
+ /** The adapter's dependency thunks, owned by the registering plugin. */
48
+ export interface LemonadeAdapterConfig {
49
+ /** Current connection facts; re-resolved per operation, never cached across calls. */
50
+ options(): LemonadeOptions;
51
+ /** Current bearer token, or `undefined` when the endpoint is unauthenticated. */
52
+ resolveApiKey(): Promise<string | undefined>;
53
+ /** The attachment service, when one is mounted (needed to send images). */
54
+ resolveAttachments(): AttachmentStore | undefined;
55
+ }
56
+ /** One Lemonade model entry as read from `GET /v1/models`. */
57
+ export interface LemonadeModelEntry {
58
+ id: string;
59
+ maxContextWindow?: number;
60
+ labels?: string[];
61
+ vision?: boolean;
62
+ }
63
+ /**
64
+ * Read one Lemonade model listing, filtering out models that are not chat
65
+ * completions targets (non-downloaded entries, and entries whose deployment
66
+ * labels route them to another endpoint).
67
+ */
68
+ export declare function fetchModelEntries(baseURL: string, apiKey: string | undefined, signal?: AbortSignal): Promise<LemonadeModelEntry[]>;
69
+ /**
70
+ * Interrogate one Lemonade endpoint for the models it advertises, mapped to
71
+ * the harness discovery vocabulary (id + optional context window).
72
+ */
73
+ export declare function discoverModels(baseURL: string, apiKey: string | undefined, signal?: AbortSignal): Promise<readonly LlmDiscoveredModel[]>;
74
+ /**
75
+ * The Lemonade adapter. One instance serves every model name it is registered
76
+ * under (the harness model name IS the wire model name).
77
+ *
78
+ * One stable signal reaches both the initial fetch and the body reads. Caller
79
+ * aborts map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`.
80
+ */
81
+ export declare class LemonadeAdapter extends LlmAdapter {
82
+ private readonly config;
83
+ /** The most recent successful live listing, keyed by model id (advisory cache, never authoritative). */
84
+ private lastKnown;
85
+ constructor(config: LemonadeAdapterConfig);
86
+ providerInfo(provider: string): LlmProviderInfo;
87
+ providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined;
88
+ listModels(provider: string): Promise<readonly LlmModelInfo[]>;
89
+ resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo>;
90
+ stream(options: GenerateOptions): AsyncGenerator<StreamChunk>;
91
+ /** Build the image resolver from the mounted attachment service, if any. */
92
+ private resolveImage;
93
+ private request;
94
+ }