@cyrilmarin/dsh-lemonade 0.2.1 → 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.fr.md +6 -0
- package/README.md +21 -1
- package/lib/adapter.js +5 -4
- package/lib/client.js +200 -8
- package/lib/index.js +33 -21
- 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 +6 -2
- package/src/adapter.ts +11 -4
- package/src/client/index.js +200 -8
- package/src/index.ts +32 -15
- package/src/json-parse.ts +175 -0
- package/src/server-api.ts +329 -30
- package/src/translate.ts +71 -5
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,20 +187,36 @@ 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
|
}
|
|
186
194
|
|
|
187
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* Resolve one credential reference through the credentials seam, falling back
|
|
197
|
+
* to the launch environment. Never returns a blank value; `undefined` when the
|
|
198
|
+
* key is unconfigured. Shared by the adapter bearer-token resolver (which may
|
|
199
|
+
* additionally enforce `requireAuth`) and by the host proxy key resolver.
|
|
200
|
+
*/
|
|
201
|
+
async function resolveCredential(ctx: Context, ref: CredentialRef): Promise<string | undefined> {
|
|
202
|
+
let value: string | undefined;
|
|
203
|
+
const credentials = ctx.get('credentials');
|
|
204
|
+
if (credentials !== undefined) value = (await credentials.resolve(ref))?.value;
|
|
205
|
+
if (value === undefined) value = launchEnvironmentOf(ctx).get(ref)?.value;
|
|
206
|
+
if (value === undefined || value.length === 0) return undefined;
|
|
207
|
+
return assertUsableApiKey(value, 'llm-lemonade', String(ref));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Resolve the optional bearer token, enforcing `requireAuth` when configured.
|
|
212
|
+
* The credentials-or-env resolution itself is delegated to resolveCredential.
|
|
213
|
+
*/
|
|
188
214
|
function makeResolveApiKey(ctx: Context, options: () => LemonadeOptions): () => Promise<string | undefined> {
|
|
189
215
|
return async () => {
|
|
190
216
|
const connection = options();
|
|
191
217
|
const ref = connection.apiKeyEnv;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
if (credentials !== undefined) value = (await credentials.resolve(ref))?.value;
|
|
195
|
-
if (value === undefined) value = launchEnvironmentOf(ctx).get(ref)?.value;
|
|
196
|
-
if (value === undefined || value.length === 0) {
|
|
218
|
+
const value = await resolveCredential(ctx, ref);
|
|
219
|
+
if (value === undefined) {
|
|
197
220
|
if (connection.requireAuth) {
|
|
198
221
|
throw new LlmError(
|
|
199
222
|
`llm-lemonade: no API key for provider route "${PROVIDER}"; store ${String(ref)} through the credentials service (the web Models page writes it), or export ${String(ref)} in the launching environment`,
|
|
@@ -202,7 +225,7 @@ function makeResolveApiKey(ctx: Context, options: () => LemonadeOptions): () =>
|
|
|
202
225
|
}
|
|
203
226
|
return undefined;
|
|
204
227
|
}
|
|
205
|
-
return
|
|
228
|
+
return value;
|
|
206
229
|
};
|
|
207
230
|
}
|
|
208
231
|
|
|
@@ -237,6 +260,7 @@ export function apply(ctx: Context, config: LemonadeRawConfig): void {
|
|
|
237
260
|
options,
|
|
238
261
|
resolveApiKey,
|
|
239
262
|
resolveAttachments: () => ctx.get('attachments'),
|
|
263
|
+
logger: () => ctx.logger,
|
|
240
264
|
});
|
|
241
265
|
|
|
242
266
|
ctx.llm.registerConfigurableProviders([
|
|
@@ -275,14 +299,7 @@ export function apply(ctx: Context, config: LemonadeRawConfig): void {
|
|
|
275
299
|
// Lemonade-specific API proxy: browser client half calls these routes
|
|
276
300
|
// same-origin; the keys are resolved host-side and never reach the browser.
|
|
277
301
|
// Per-endpoint key selection (regular vs admin) lives in server-api.ts.
|
|
278
|
-
const resolveKey =
|
|
279
|
-
let value: string | undefined;
|
|
280
|
-
const credentials = ctx.get('credentials');
|
|
281
|
-
if (credentials !== undefined) value = (await credentials.resolve(ref))?.value;
|
|
282
|
-
if (value === undefined) value = launchEnvironmentOf(ctx).get(ref)?.value;
|
|
283
|
-
if (value === undefined || value.length === 0) return undefined;
|
|
284
|
-
return assertUsableApiKey(value, 'llm-lemonade', String(ref));
|
|
285
|
-
};
|
|
302
|
+
const resolveKey = (ref: CredentialRef): Promise<string | undefined> => resolveCredential(ctx, ref);
|
|
286
303
|
const apiCfg = {
|
|
287
304
|
baseURL: () => options().baseURL,
|
|
288
305
|
requireAuth: () => options().requireAuth,
|
|
@@ -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
|
+
}
|