@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/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
- /** Resolve the optional bearer token, enforcing `requireAuth` when configured. */
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
- let value: string | undefined;
193
- const credentials = ctx.get('credentials');
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 assertUsableApiKey(value, 'llm-lemonade', String(ref));
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 = async (ref: CredentialRef): Promise<string | undefined> => {
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
+ }