@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.
- package/README.fr.md +186 -0
- package/README.md +189 -0
- package/lib/adapter.js +287 -0
- package/lib/client.js +561 -0
- package/lib/index.js +263 -0
- package/lib/serialize.js +113 -0
- package/lib/server-api.js +356 -0
- package/lib/translate.js +163 -0
- package/lib/types/adapter.d.ts +94 -0
- package/lib/types/index.d.ts +91 -0
- package/lib/types/serialize.d.ts +68 -0
- package/lib/types/server-api.d.ts +77 -0
- package/lib/types/translate.d.ts +10 -0
- package/package.json +94 -0
- package/src/adapter.ts +381 -0
- package/src/client/index.js +561 -0
- package/src/index.ts +323 -0
- package/src/serialize.ts +171 -0
- package/src/server-api.ts +402 -0
- package/src/translate.ts +190 -0
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
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 {
|
|
15
|
+
CONTEXT_WINDOW_EXCEEDED_CODE,
|
|
16
|
+
LlmAdapter,
|
|
17
|
+
LlmError,
|
|
18
|
+
ProviderRequestId,
|
|
19
|
+
QUOTA_EXCEEDED_CODE,
|
|
20
|
+
attributionHeaders,
|
|
21
|
+
isContextWindowExceededError,
|
|
22
|
+
isQuotaExceededError,
|
|
23
|
+
} from '@deepseek-ai/dsh-llm';
|
|
24
|
+
import type {
|
|
25
|
+
GenerateOptions,
|
|
26
|
+
LlmDiscoveredModel,
|
|
27
|
+
LlmModelInfo,
|
|
28
|
+
LlmProviderInfo,
|
|
29
|
+
LlmResolvedModelInfo,
|
|
30
|
+
ResolvedRetryPolicy,
|
|
31
|
+
StreamChunk,
|
|
32
|
+
} from '@deepseek-ai/dsh-llm';
|
|
33
|
+
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout';
|
|
34
|
+
import { serializeRequest, type ResolveImage } from './serialize.js';
|
|
35
|
+
import { parseSse, translate } from './translate.js';
|
|
36
|
+
|
|
37
|
+
/** Default endpoint (baseURL is the server root; /v1 paths are appended by each endpoint builder). */
|
|
38
|
+
export const DEFAULT_BASE_URL = 'http://localhost:13305';
|
|
39
|
+
/** Default combined request/response context capacity for models with no metadata. */
|
|
40
|
+
export const DEFAULT_CONTEXT_WINDOW = 32768;
|
|
41
|
+
/** Default per-request output-token cap. */
|
|
42
|
+
export const DEFAULT_MAX_TOKENS = 8192;
|
|
43
|
+
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
|
44
|
+
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
45
|
+
/** Maximum time one live model-listing query may take. */
|
|
46
|
+
export const LISTING_TIMEOUT_MS = 5_000;
|
|
47
|
+
const STREAM_IDLE_TIMEOUT_CODE = 'LLM_STREAM_IDLE_TIMEOUT';
|
|
48
|
+
|
|
49
|
+
/** One entry of the user-pinned advisory model catalog. */
|
|
50
|
+
export interface LemonadeCatalogModel {
|
|
51
|
+
id: string;
|
|
52
|
+
name?: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
contextWindow?: number;
|
|
55
|
+
maxTokens?: number;
|
|
56
|
+
vision?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Validated connection facts resolved from raw config and the environment. */
|
|
60
|
+
export interface LemonadeOptions {
|
|
61
|
+
apiKeyEnv: CredentialRef;
|
|
62
|
+
adminApiKeyEnv: CredentialRef;
|
|
63
|
+
baseURL: string;
|
|
64
|
+
requireAuth: boolean;
|
|
65
|
+
defaultContextWindow: number;
|
|
66
|
+
maxTokens: number;
|
|
67
|
+
models: LemonadeCatalogModel[];
|
|
68
|
+
streamIdleTimeoutMs: number;
|
|
69
|
+
retryPolicy: ResolvedRetryPolicy;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The adapter's dependency thunks, owned by the registering plugin. */
|
|
73
|
+
export interface LemonadeAdapterConfig {
|
|
74
|
+
/** Current connection facts; re-resolved per operation, never cached across calls. */
|
|
75
|
+
options(): LemonadeOptions;
|
|
76
|
+
/** Current bearer token, or `undefined` when the endpoint is unauthenticated. */
|
|
77
|
+
resolveApiKey(): Promise<string | undefined>;
|
|
78
|
+
/** The attachment service, when one is mounted (needed to send images). */
|
|
79
|
+
resolveAttachments(): AttachmentStore | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One Lemonade model entry as read from `GET /v1/models`. */
|
|
83
|
+
export interface LemonadeModelEntry {
|
|
84
|
+
id: string;
|
|
85
|
+
maxContextWindow?: number;
|
|
86
|
+
labels?: string[];
|
|
87
|
+
vision?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Deployment labels that route a model to a non-chat endpoint; such models are
|
|
92
|
+
* excluded from the chat model listing. Characteristic labels (vision,
|
|
93
|
+
* reasoning, tool-calling, …) and chat-capable modality labels are kept.
|
|
94
|
+
*/
|
|
95
|
+
const NON_CHAT_LABELS = new Set(['transcription', 'embeddings', 'reranking', 'image', 'edit', 'tts']);
|
|
96
|
+
|
|
97
|
+
/** Map an HTTP status to a stable LlmError code. */
|
|
98
|
+
function httpErrorCode(
|
|
99
|
+
status: number,
|
|
100
|
+
error: { code?: unknown; type?: unknown; message?: unknown } | undefined,
|
|
101
|
+
): string {
|
|
102
|
+
if (status === 401 || status === 403) return 'AUTH';
|
|
103
|
+
const detail = [error?.code, error?.type, error?.message].filter(Boolean).join(' ');
|
|
104
|
+
if (isQuotaExceededError(detail)) return QUOTA_EXCEEDED_CODE;
|
|
105
|
+
if (status === 429) return 'RATE_LIMIT';
|
|
106
|
+
if (status === 400) {
|
|
107
|
+
if (isContextWindowExceededError(detail)) return CONTEXT_WINDOW_EXCEEDED_CODE;
|
|
108
|
+
return 'INVALID_REQUEST';
|
|
109
|
+
}
|
|
110
|
+
if (status >= 500) return 'SERVER';
|
|
111
|
+
return `HTTP_${status}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function providerRetryAfterMs(value: string | null): number | undefined {
|
|
115
|
+
if (value === null) return undefined;
|
|
116
|
+
if (/^\d+$/.test(value)) {
|
|
117
|
+
const delay = Number(value) * 1_000;
|
|
118
|
+
return Number.isFinite(delay) && delay > 0 ? delay : undefined;
|
|
119
|
+
}
|
|
120
|
+
const delay = Date.parse(value) - Date.now();
|
|
121
|
+
return Number.isFinite(delay) && delay > 0 ? delay : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function requestId(headers: Headers): ProviderRequestId | undefined {
|
|
125
|
+
const value = headers.get('x-request-id') ?? headers.get('x-lemonade-request-id');
|
|
126
|
+
return value === null || value.length === 0 ? undefined : ProviderRequestId(value);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read one Lemonade model listing, filtering out models that are not chat
|
|
131
|
+
* completions targets (non-downloaded entries, and entries whose deployment
|
|
132
|
+
* labels route them to another endpoint).
|
|
133
|
+
*/
|
|
134
|
+
export async function fetchModelEntries(
|
|
135
|
+
baseURL: string,
|
|
136
|
+
apiKey: string | undefined,
|
|
137
|
+
signal?: AbortSignal,
|
|
138
|
+
): Promise<LemonadeModelEntry[]> {
|
|
139
|
+
const url = `${baseURL}/v1/models`;
|
|
140
|
+
const headers: Record<string, string> = { accept: 'application/json', ...attributionHeaders() };
|
|
141
|
+
if (apiKey !== undefined) headers.authorization = `Bearer ${apiKey}`;
|
|
142
|
+
let response: Response;
|
|
143
|
+
try {
|
|
144
|
+
response = await fetch(url, { method: 'GET', headers, signal });
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (signal?.aborted) throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error });
|
|
147
|
+
throw new LlmError(`could not reach ${url}`, 'DISCOVERY_FAILED', { cause: error });
|
|
148
|
+
}
|
|
149
|
+
if (!response.ok) {
|
|
150
|
+
throw new LlmError(
|
|
151
|
+
`${url} answered ${response.status}${response.status === 401 || response.status === 403 ? '; check the API key' : ''}`,
|
|
152
|
+
'DISCOVERY_FAILED',
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
let body: { data?: unknown } | undefined;
|
|
156
|
+
try {
|
|
157
|
+
body = await response.json() as { data?: unknown };
|
|
158
|
+
} catch (error) {
|
|
159
|
+
throw new LlmError(`${url} did not answer with JSON`, 'DISCOVERY_FAILED', { cause: error });
|
|
160
|
+
}
|
|
161
|
+
const data = body?.data;
|
|
162
|
+
if (!Array.isArray(data)) {
|
|
163
|
+
throw new LlmError(`${url} has no "data" array; enter this server's models by hand`, 'DISCOVERY_FAILED');
|
|
164
|
+
}
|
|
165
|
+
const entries: LemonadeModelEntry[] = [];
|
|
166
|
+
const seen = new Set<string>();
|
|
167
|
+
for (const raw of data) {
|
|
168
|
+
const record = (raw ?? {}) as Record<string, unknown>;
|
|
169
|
+
const id = typeof record['id'] === 'string' ? (record['id'] as string) : '';
|
|
170
|
+
if (id.length === 0 || seen.has(id)) continue;
|
|
171
|
+
// Skip alias entries (Lemonade Server exposes model aliases alongside real
|
|
172
|
+
// models; an alias has a "model" field pointing to its target).
|
|
173
|
+
if (typeof record['model'] === 'string' && record['model'].length > 0) continue;
|
|
174
|
+
if (record['downloaded'] === false) continue;
|
|
175
|
+
const labels = Array.isArray(record['labels'])
|
|
176
|
+
? (record['labels'] as unknown[]).filter((label): label is string => typeof label === 'string')
|
|
177
|
+
: [];
|
|
178
|
+
if (labels.some((label) => NON_CHAT_LABELS.has(label))) continue;
|
|
179
|
+
seen.add(id);
|
|
180
|
+
const maxContextWindow =
|
|
181
|
+
typeof record['max_context_window'] === 'number' && (record['max_context_window'] as number) > 0
|
|
182
|
+
? (record['max_context_window'] as number)
|
|
183
|
+
: undefined;
|
|
184
|
+
entries.push({
|
|
185
|
+
id,
|
|
186
|
+
...(maxContextWindow !== undefined ? { maxContextWindow } : {}),
|
|
187
|
+
...(labels.length > 0 ? { labels } : {}),
|
|
188
|
+
...(labels.includes('vision') ? { vision: true } : {}),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return entries;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Interrogate one Lemonade endpoint for the models it advertises, mapped to
|
|
196
|
+
* the harness discovery vocabulary (id + optional context window).
|
|
197
|
+
*/
|
|
198
|
+
export async function discoverModels(
|
|
199
|
+
baseURL: string,
|
|
200
|
+
apiKey: string | undefined,
|
|
201
|
+
signal?: AbortSignal,
|
|
202
|
+
): Promise<readonly LlmDiscoveredModel[]> {
|
|
203
|
+
const entries = await fetchModelEntries(baseURL, apiKey, signal);
|
|
204
|
+
return entries.map((entry) => ({
|
|
205
|
+
id: entry.id,
|
|
206
|
+
...(entry.maxContextWindow !== undefined ? { contextWindow: entry.maxContextWindow } : {}),
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Build display metadata for one model. */
|
|
211
|
+
function modelInfo(
|
|
212
|
+
provider: string,
|
|
213
|
+
id: string,
|
|
214
|
+
live: LemonadeModelEntry | undefined,
|
|
215
|
+
configured?: LemonadeCatalogModel,
|
|
216
|
+
): LlmModelInfo {
|
|
217
|
+
return {
|
|
218
|
+
provider,
|
|
219
|
+
id,
|
|
220
|
+
name: configured?.name ?? id,
|
|
221
|
+
...(configured?.description !== undefined ? { description: configured.description } : {}),
|
|
222
|
+
inputModalities: live?.vision === true || configured?.vision === true ? ['text', 'image'] : ['text'],
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The Lemonade adapter. One instance serves every model name it is registered
|
|
228
|
+
* under (the harness model name IS the wire model name).
|
|
229
|
+
*
|
|
230
|
+
* One stable signal reaches both the initial fetch and the body reads. Caller
|
|
231
|
+
* aborts map to `ABORTED`; the configured per-read idle watchdog maps to `TIMEOUT`.
|
|
232
|
+
*/
|
|
233
|
+
export class LemonadeAdapter extends LlmAdapter {
|
|
234
|
+
private readonly config: LemonadeAdapterConfig;
|
|
235
|
+
|
|
236
|
+
/** The most recent successful live listing, keyed by model id (advisory cache, never authoritative). */
|
|
237
|
+
private lastKnown = new Map<string, LemonadeModelEntry>();
|
|
238
|
+
|
|
239
|
+
constructor(config: LemonadeAdapterConfig) {
|
|
240
|
+
super();
|
|
241
|
+
this.config = config;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
override providerInfo(provider: string): LlmProviderInfo {
|
|
245
|
+
return { id: provider, name: 'Lemonade' };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined {
|
|
249
|
+
return this.config.options().retryPolicy;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
|
253
|
+
const options = this.config.options();
|
|
254
|
+
// The configured catalog IS the selection: when models are pinned in the
|
|
255
|
+
// plugin configuration, the model selector offers ONLY those.
|
|
256
|
+
if (options.models.length > 0) {
|
|
257
|
+
return options.models.map((model) => modelInfo(provider, model.id, undefined, model));
|
|
258
|
+
}
|
|
259
|
+
// No configured selection: advertise whatever the server currently offers.
|
|
260
|
+
try {
|
|
261
|
+
const apiKey = await this.config.resolveApiKey();
|
|
262
|
+
const entries = await fetchModelEntries(options.baseURL, apiKey, AbortSignal.timeout(LISTING_TIMEOUT_MS));
|
|
263
|
+
this.lastKnown = new Map(entries.map((entry) => [entry.id, entry]));
|
|
264
|
+
return entries.map((entry) => modelInfo(provider, entry.id, entry));
|
|
265
|
+
} catch {
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
override async resolveModel(provider: string, model: string, _signal?: AbortSignal): Promise<LlmResolvedModelInfo> {
|
|
271
|
+
const options = this.config.options();
|
|
272
|
+
const configured = options.models.find((entry) => entry.id === model);
|
|
273
|
+
const live = this.lastKnown.get(model);
|
|
274
|
+
const contextWindow = configured?.contextWindow ?? live?.maxContextWindow ?? options.defaultContextWindow;
|
|
275
|
+
return {
|
|
276
|
+
...modelInfo(provider, model, live, configured),
|
|
277
|
+
context: { contextWindow },
|
|
278
|
+
defaultMaxTokens: configured?.maxTokens ?? options.maxTokens,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
override async *stream(options: GenerateOptions): AsyncGenerator<StreamChunk> {
|
|
283
|
+
const connection = this.config.options();
|
|
284
|
+
const apiKey = await this.config.resolveApiKey();
|
|
285
|
+
const consumer = new AbortController();
|
|
286
|
+
const watchdog = idleWatchdog(
|
|
287
|
+
options.signal === undefined ? consumer.signal : AbortSignal.any([options.signal, consumer.signal]),
|
|
288
|
+
connection.streamIdleTimeoutMs,
|
|
289
|
+
STREAM_IDLE_TIMEOUT_CODE,
|
|
290
|
+
);
|
|
291
|
+
const iterator = this.request(options, watchdog.signal, connection, apiKey, () => watchdog.pulse())[Symbol.asyncIterator]();
|
|
292
|
+
let exhausted = false;
|
|
293
|
+
try {
|
|
294
|
+
while (true) {
|
|
295
|
+
const result = await watchdog.next(iterator);
|
|
296
|
+
if (result.done) {
|
|
297
|
+
exhausted = true;
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
yield result.value;
|
|
301
|
+
}
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (timeoutOf(watchdog.signal, STREAM_IDLE_TIMEOUT_CODE) !== undefined) {
|
|
304
|
+
throw new LlmError(`Lemonade stream idle timeout after ${connection.streamIdleTimeoutMs}ms`, 'TIMEOUT', { cause: error });
|
|
305
|
+
}
|
|
306
|
+
if (options.signal?.aborted) throw new LlmError('Lemonade request aborted by caller', 'ABORTED', { cause: error });
|
|
307
|
+
if (error instanceof LlmError) throw error;
|
|
308
|
+
throw new LlmError(`Lemonade API stream from ${connection.baseURL} failed`, 'TRANSPORT', { cause: error });
|
|
309
|
+
} finally {
|
|
310
|
+
consumer.abort('Lemonade stream consumer stopped');
|
|
311
|
+
watchdog[Symbol.dispose]();
|
|
312
|
+
if (!exhausted && iterator.return !== undefined) {
|
|
313
|
+
try {
|
|
314
|
+
await iterator.return(undefined);
|
|
315
|
+
} catch {
|
|
316
|
+
// transport teardown — the streaming error (if any) already surfaced above
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Build the image resolver from the mounted attachment service, if any. */
|
|
323
|
+
private async resolveImage(signal?: AbortSignal): Promise<ResolveImage | undefined> {
|
|
324
|
+
const attachments = this.config.resolveAttachments();
|
|
325
|
+
if (attachments === undefined) return undefined;
|
|
326
|
+
return async (ref) => {
|
|
327
|
+
const stored = await attachments.readImage(ref, signal);
|
|
328
|
+
const base64 = Buffer.from(stored.data).toString('base64');
|
|
329
|
+
return `data:${stored.ref.mediaType};base64,${base64}`;
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private async *request(
|
|
334
|
+
options: GenerateOptions,
|
|
335
|
+
signal: AbortSignal,
|
|
336
|
+
connection: LemonadeOptions,
|
|
337
|
+
apiKey: string | undefined,
|
|
338
|
+
onComment: () => void,
|
|
339
|
+
): AsyncGenerator<StreamChunk> {
|
|
340
|
+
const resolveImage = await this.resolveImage(signal);
|
|
341
|
+
const body = await serializeRequest(options, resolveImage);
|
|
342
|
+
const headers: Record<string, string> = {
|
|
343
|
+
'content-type': 'application/json',
|
|
344
|
+
accept: 'text/event-stream',
|
|
345
|
+
...attributionHeaders(),
|
|
346
|
+
...(apiKey !== undefined ? { authorization: `Bearer ${apiKey}` } : {}),
|
|
347
|
+
};
|
|
348
|
+
const url = `${connection.baseURL}/v1/chat/completions`;
|
|
349
|
+
let response: Response;
|
|
350
|
+
try {
|
|
351
|
+
response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal });
|
|
352
|
+
} catch (error) {
|
|
353
|
+
if (signal.aborted) throw error;
|
|
354
|
+
throw new LlmError(`Lemonade API request to ${url} failed`, 'TRANSPORT', { cause: error });
|
|
355
|
+
}
|
|
356
|
+
if (!response.ok) {
|
|
357
|
+
let message = `Lemonade API error (HTTP ${response.status})`;
|
|
358
|
+
let providerError: { message?: unknown; type?: unknown; code?: unknown } | undefined;
|
|
359
|
+
try {
|
|
360
|
+
const parsed = await response.json() as { error?: unknown };
|
|
361
|
+
if (parsed && typeof parsed === 'object' && parsed.error && typeof parsed.error === 'object') {
|
|
362
|
+
providerError = parsed.error as { message?: unknown; type?: unknown; code?: unknown };
|
|
363
|
+
if (typeof providerError.message === 'string' && providerError.message.length > 0) {
|
|
364
|
+
message = providerError.message;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} catch {
|
|
368
|
+
// no parseable error body — keep the generic message
|
|
369
|
+
}
|
|
370
|
+
const delay = providerRetryAfterMs(response.headers.get('retry-after'));
|
|
371
|
+
const id = requestId(response.headers);
|
|
372
|
+
throw new LlmError(message, httpErrorCode(response.status, providerError), {
|
|
373
|
+
status: response.status,
|
|
374
|
+
...(delay !== undefined ? { providerRetryAfterMs: delay } : {}),
|
|
375
|
+
...(id !== undefined ? { requestId: id } : {}),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
if (!response.body) throw new LlmError('Lemonade API returned no response body', 'EMPTY_RESPONSE');
|
|
379
|
+
yield* translate(parseSse(response.body, onComment));
|
|
380
|
+
}
|
|
381
|
+
}
|