@0xmaxma/claude-gateway 1.3.31 → 1.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.
@@ -239,7 +239,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
239
239
  return;
240
240
  }
241
241
  const body = req.body;
242
- const { message, chat_id, session_id, stream, timeout_ms, media_files, model: requestModel, store_user_message } = body;
242
+ const { message, chat_id, session_id, stream, timeout_ms, media_files, model: requestModel, store_user_message, image_params } = body;
243
243
  if (message !== undefined && typeof message !== 'string') {
244
244
  res.status(400).json({ error: 'message must be a string if provided' });
245
245
  return;
@@ -284,6 +284,38 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
284
284
  }
285
285
  validatedMediaFiles = media_files;
286
286
  }
287
+ // Validate optional image_params (contract E5) — surfaced to the agent so it
288
+ // calls generate_image with the composer-selected options.
289
+ let validatedImageParams;
290
+ if (image_params !== undefined) {
291
+ if (typeof image_params !== 'object' || image_params === null || Array.isArray(image_params)) {
292
+ res.status(400).json({ error: 'image_params must be an object if provided' });
293
+ return;
294
+ }
295
+ const ip = image_params;
296
+ const strFields = ['model', 'quality', 'size', 'aspect_ratio', 'image_ref'];
297
+ const out = {};
298
+ for (const f of strFields) {
299
+ const v = ip[f];
300
+ if (v !== undefined) {
301
+ if (typeof v !== 'string') {
302
+ res.status(400).json({ error: `image_params.${f} must be a string` });
303
+ return;
304
+ }
305
+ if (v.trim())
306
+ out[f] = v.trim();
307
+ }
308
+ }
309
+ if (ip.n !== undefined) {
310
+ if (typeof ip.n !== 'number' || !Number.isFinite(ip.n) || ip.n < 1) {
311
+ res.status(400).json({ error: 'image_params.n must be a positive number' });
312
+ return;
313
+ }
314
+ out.n = Math.floor(ip.n);
315
+ }
316
+ if (Object.keys(out).length)
317
+ validatedImageParams = out;
318
+ }
287
319
  // Allow message OR media_files. Image-only sends pass an empty text
288
320
  // alongside the image_path attribute on channelXml so Claude can Read the file.
289
321
  const trimmedMessage = typeof message === 'string' ? message.trim() : '';
@@ -380,7 +412,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
380
412
  res.socket?.setNoDelay(true);
381
413
  const agentCfg = agentConfigs.get(agentId);
382
414
  const allowTools = agentCfg.allow_tools ?? !!apiKey.allow_tools;
383
- onClientDisconnect = await runner.sendApiMessageStream(sessionId, chatIdStr, trimmedMessage, sseCallbacks, { timeoutMs, allowTools, mediaFiles: validatedMediaFiles, model: modelStr, skipUserMessage });
415
+ onClientDisconnect = await runner.sendApiMessageStream(sessionId, chatIdStr, trimmedMessage, sseCallbacks, { timeoutMs, allowTools, mediaFiles: validatedMediaFiles, model: modelStr, skipUserMessage, imageParams: validatedImageParams });
384
416
  // Client disconnect — marks SSE writes as no-op; stream continues server-side until result is saved to DB
385
417
  res.on('close', onClientDisconnect);
386
418
  }
@@ -421,6 +453,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
421
453
  mediaFiles: validatedMediaFiles,
422
454
  model: modelStr,
423
455
  skipUserMessage,
456
+ imageParams: validatedImageParams,
424
457
  }));
425
458
  }
426
459
  const syncResult = {
@@ -533,7 +566,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
533
566
  description: cfg?.description ?? '',
534
567
  sessions: sessions.map((s) => {
535
568
  const meta = metaMap.get(s.sessionId);
536
- return { ...s, sessionName: meta?.name ?? null };
569
+ return { ...s, sessionName: meta?.name ?? null, imageConfig: meta?.imageConfig ?? null };
537
570
  }),
538
571
  };
539
572
  }));
@@ -2201,7 +2234,7 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2201
2234
  /**
2202
2235
  * GET /api/v1/agents/:agentId/chats/:chatId/messages
2203
2236
  * Paginated message history (cursor-based).
2204
- * Query: limit, before (ts ms), after (ts ms), session_id
2237
+ * Query: limit, before (ts ms), after (ts ms), session_id, order (asc|desc, default desc)
2205
2238
  */
2206
2239
  router.get('/v1/agents/:agentId/chats/:chatId/messages', auth, (req, res) => {
2207
2240
  const { agentId, chatId } = req.params;
@@ -2217,10 +2250,54 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2217
2250
  }
2218
2251
  const query = req.query;
2219
2252
  const limit = query['limit'] ? Math.min(parseInt(query['limit'], 10) || 50, 200) : 50;
2220
- const before = query['before'] ? parseInt(query['before'], 10) : undefined;
2221
- const after = query['after'] ? parseInt(query['after'], 10) : undefined;
2253
+ // Numeric cursor params. before/after are ms timestamps; before_id/after_id are the id
2254
+ // component of the composite (ts, id) cursor, echoed from a prior page's nextCursorId —
2255
+ // paired with before/after they stop paging from skipping messages that share a ts
2256
+ // (ignored unless the matching before/after is present). A present-but-non-numeric value
2257
+ // (?before=abc, or a duplicate/structured param) is a malformed request: reject with 400
2258
+ // so client bugs surface, mirroring the `order` guard below, rather than coercing to NaN
2259
+ // and silently returning an empty page.
2260
+ const cursorInts = {};
2261
+ for (const name of ['before', 'after', 'before_id', 'after_id']) {
2262
+ const raw = query[name];
2263
+ if (raw === undefined)
2264
+ continue;
2265
+ const n = typeof raw === 'string' ? parseInt(raw, 10) : NaN;
2266
+ if (!Number.isFinite(n)) {
2267
+ res.status(400).json({ error: `${name} must be a number` });
2268
+ return;
2269
+ }
2270
+ cursorInts[name] = n;
2271
+ }
2272
+ const before = cursorInts['before'];
2273
+ const after = cursorInts['after'];
2274
+ const beforeId = cursorInts['before_id'];
2275
+ const afterId = cursorInts['after_id'];
2222
2276
  const sessionId = query['session_id'] ?? undefined;
2223
- const page = runner.getHistoryDb().getMessages(chatId, { limit, before, after, sessionId });
2277
+ // order: case-insensitive; 'asc' seeks forward, 'desc' (or omitted) is the db default.
2278
+ // Reject any other explicit value with 400 so client typos surface instead of silently defaulting.
2279
+ let order;
2280
+ const rawOrder = query['order'];
2281
+ if (rawOrder !== undefined) {
2282
+ // Express parses a repeated/structured param (?order=asc&order=asc) as an
2283
+ // array/object, not a string — guard so .toLowerCase() can't throw a 500.
2284
+ if (typeof rawOrder !== 'string') {
2285
+ res.status(400).json({ error: "order must be 'asc' or 'desc'" });
2286
+ return;
2287
+ }
2288
+ const normalized = rawOrder.toLowerCase();
2289
+ if (normalized === 'asc') {
2290
+ order = 'asc';
2291
+ }
2292
+ else if (normalized === 'desc') {
2293
+ order = undefined; // explicit desc == db default
2294
+ }
2295
+ else {
2296
+ res.status(400).json({ error: "order must be 'asc' or 'desc'" });
2297
+ return;
2298
+ }
2299
+ }
2300
+ const page = runner.getHistoryDb().getMessages(chatId, { limit, before, after, beforeId, afterId, sessionId, order });
2224
2301
  res.json(page);
2225
2302
  });
2226
2303
  /**
@@ -2251,6 +2328,62 @@ function createApiRouter(agentRunners, agentConfigs, apiKeys, configPath, models
2251
2328
  const page = runner.getHistoryDb().searchMessages(chatId, q, { limit, offset });
2252
2329
  res.json(page);
2253
2330
  });
2331
+ /**
2332
+ * GET /api/v1/agents/:agentId/chats/:chatId/messages/active-days
2333
+ * Distinct local calendar days (YYYY-MM-DD) with >= 1 message in a [from, to) window.
2334
+ * Powers the jump-to-date calendar's per-day "has history" dot in one bounded index scan.
2335
+ * Query: from (ts ms, inclusive), to (ts ms, exclusive), tz_offset (min east of UTC, Bangkok=+420), session_id
2336
+ */
2337
+ router.get('/v1/agents/:agentId/chats/:chatId/messages/active-days', auth, (req, res) => {
2338
+ const { agentId, chatId } = req.params;
2339
+ const apiKey = req.apiKey;
2340
+ if (!(0, auth_1.canAccessAgent)(apiKey, agentId)) {
2341
+ res.status(403).json({ error: `API key has no access to agent '${agentId}'` });
2342
+ return;
2343
+ }
2344
+ const runner = agentRunners.get(agentId);
2345
+ if (!runner) {
2346
+ res.status(404).json({ error: `Agent '${agentId}' not found` });
2347
+ return;
2348
+ }
2349
+ const query = req.query;
2350
+ // Number(), not parseInt() — parseInt("100garbage") silently returns 100, masking a malformed
2351
+ // client value the same way a case-sensitive/silently-defaulting enum param would (see order).
2352
+ const from = query['from'] ? Number(query['from']) : NaN;
2353
+ const to = query['to'] ? Number(query['to']) : NaN;
2354
+ if (!Number.isFinite(from) || !Number.isFinite(to)) {
2355
+ res.status(400).json({ error: 'from and to (ts ms) are required' });
2356
+ return;
2357
+ }
2358
+ // Bound the window so a malformed client can't turn this into a near-full-history scan.
2359
+ // 366 days is far wider than the one-month view the calendar sends, so it never bites
2360
+ // legitimate navigation while still capping a pathological range (E5 in the design notes).
2361
+ const MAX_ACTIVE_DAYS_SPAN_MS = 366 * 24 * 60 * 60 * 1000;
2362
+ if (to - from > MAX_ACTIVE_DAYS_SPAN_MS) {
2363
+ res.status(400).json({ error: 'window too large (max 366 days between from and to)' });
2364
+ return;
2365
+ }
2366
+ let tzOffset = 0;
2367
+ if (query['tz_offset'] !== undefined) {
2368
+ const parsed = Number(query['tz_offset']);
2369
+ if (!Number.isFinite(parsed)) {
2370
+ res.status(400).json({ error: 'tz_offset must be a number (minutes east of UTC)' });
2371
+ return;
2372
+ }
2373
+ tzOffset = parsed;
2374
+ }
2375
+ // session_id, like order, can arrive as an array when the param is repeated
2376
+ // (?session_id=a&session_id=b). Guard so it can't reach the sqlite bind as an
2377
+ // array and throw a 500 — surface a 400 instead. (Mirrors the order guard above.)
2378
+ const rawSessionId = query['session_id'];
2379
+ if (rawSessionId !== undefined && typeof rawSessionId !== 'string') {
2380
+ res.status(400).json({ error: 'session_id must be a single value' });
2381
+ return;
2382
+ }
2383
+ const sessionId = rawSessionId ?? undefined;
2384
+ const days = runner.getHistoryDb().getActiveDays(chatId, { from, to, tzOffset, sessionId });
2385
+ res.json({ days });
2386
+ });
2254
2387
  /**
2255
2388
  * POST /api/v1/agents/:agentId/chats/:chatId/sessions/:sessionId/messages
2256
2389
  * Inject a message into an existing channel session (cross-channel continuation).