@constructive-io/graphql-server 4.32.0 → 4.34.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.
@@ -19,91 +19,32 @@
19
19
  *
20
20
  * Auth: JWT from the auth middleware (req.token) → pg SET LOCAL context for RLS
21
21
  * Metering: check_billing_quota → LLM call → record_usage with real token counts
22
+ *
23
+ * Context: Uses `req.constructive` from @constructive-io/express-context
24
+ * for tenant-scoped database access, pgSettings, and withPgClient.
22
25
  */
23
26
  import express, { Router } from 'express';
24
27
  import { Logger } from '@pgpmjs/logger';
25
- import { getPgPool } from 'pg-cache';
26
- import OllamaClient from '@agentic-kit/ollama';
28
+ import { OllamaAdapter } from '@agentic-kit/ollama';
27
29
  import { ModuleConfigCache } from 'graphile-cache';
28
30
  import { getLlmEnvOptions, getAgentDiscovery, getLlmBillingConfig, } from 'graphile-llm';
29
31
  const log = new Logger('llm-api');
30
- /**
31
- * Placeholder: replace with actual provider token counts once generateWithUsage() is approved.
32
- * Estimates ~4 chars per token for English text.
33
- */
34
- function placeholderAmountTokens(text) {
35
- return Math.ceil(text.length / 4);
36
- }
37
- /**
38
- * Call generate() and estimate token counts from text length.
39
- * When a provider-native token counting API is approved, swap the
40
- * estimation logic here without changing call sites.
41
- */
42
- async function callWithUsage(client, input, onChunk) {
43
- const promptText = input.messages.map((m) => m.content).join(' ');
44
- let content;
45
- if (onChunk || input.stream) {
46
- await client.generate(input, onChunk);
47
- content = '';
48
- }
49
- else {
50
- content = await client.generate(input);
51
- }
52
- const inputTokens = placeholderAmountTokens(promptText);
53
- const outputTokens = placeholderAmountTokens(content);
54
- return {
55
- content,
56
- usage: { input: inputTokens, output: outputTokens, totalTokens: inputTokens + outputTokens },
57
- };
58
- }
59
32
  // ─── Helpers ────────────────────────────────────────────────────────────────
60
- function getPgSettings(req) {
61
- const settings = {};
62
- if (req.token?.user_id) {
63
- settings['jwt.claims.user_id'] = req.token.user_id;
64
- settings['role'] = 'authenticated';
65
- }
66
- if (req.databaseId) {
67
- settings['jwt.claims.database_id'] = req.databaseId;
68
- }
69
- if (req.requestId) {
70
- settings['request.id'] = req.requestId;
71
- }
72
- return settings;
73
- }
74
- async function withRlsClient(pool, pgSettings, fn) {
75
- const client = await pool.connect();
76
- try {
77
- await client.query('BEGIN');
78
- for (const [key, value] of Object.entries(pgSettings)) {
79
- await client.query('SELECT set_config($1, $2, true)', [key, value]);
80
- }
81
- const result = await fn(client);
82
- await client.query('COMMIT');
83
- return result;
84
- }
85
- catch (err) {
86
- await client.query('ROLLBACK').catch(() => { });
87
- throw err;
88
- }
89
- finally {
90
- client.release();
91
- }
92
- }
93
- function resolveOllamaClient() {
33
+ function resolveOllamaAdapter() {
94
34
  const { chat } = getLlmEnvOptions();
95
35
  if (chat.provider === 'ollama') {
96
36
  return {
97
- client: new OllamaClient(chat.baseUrl),
37
+ adapter: new OllamaAdapter(chat.baseUrl),
98
38
  model: chat.model,
39
+ baseUrl: chat.baseUrl,
99
40
  };
100
41
  }
101
42
  return null;
102
43
  }
103
44
  // ─── Billing Helpers ────────────────────────────────────────────────────────
104
- async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
45
+ async function checkQuota(ctx, billing, entityId, meterSlug) {
105
46
  try {
106
- return await withRlsClient(pool, pgSettings, async (client) => {
47
+ return await ctx.withPgClient(async (client) => {
107
48
  const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
108
49
  const result = await client.query(sql, [meterSlug, entityId, 1]);
109
50
  return result.rows[0]?.allowed !== false;
@@ -115,9 +56,9 @@ async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
115
56
  return true;
116
57
  }
117
58
  }
118
- async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amount, metadata) {
59
+ async function recordUsage(ctx, billing, entityId, meterSlug, amount, metadata) {
119
60
  try {
120
- await withRlsClient(pool, pgSettings, async (client) => {
61
+ await ctx.withPgClient(async (client) => {
121
62
  const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
122
63
  await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
123
64
  });
@@ -127,11 +68,13 @@ async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amoun
127
68
  log.warn(`[llm-api] record_usage failed (non-fatal): ${message}`);
128
69
  }
129
70
  }
130
- async function resolveBilling(pool, pgSettings, databaseId) {
71
+ async function resolveBilling(ctx) {
72
+ if (!ctx.databaseId)
73
+ return null;
131
74
  try {
132
75
  let billing = null;
133
- await withRlsClient(pool, pgSettings, async (client) => {
134
- const entry = await getLlmBillingConfig(client, databaseId);
76
+ await ctx.withPgClient(async (client) => {
77
+ const entry = await getLlmBillingConfig(client, ctx.databaseId);
135
78
  billing = entry.billing;
136
79
  });
137
80
  return billing;
@@ -170,9 +113,9 @@ async function getInferenceLogInfo(pool, dbname) {
170
113
  inferenceLogCache.set(dbname, info);
171
114
  return info;
172
115
  }
173
- async function logInference(pool, pgSettings, logInfo, data) {
116
+ async function logInference(ctx, logInfo, data) {
174
117
  try {
175
- await withRlsClient(pool, pgSettings, async (client) => {
118
+ await ctx.withPgClient(async (client) => {
176
119
  await client.query(`INSERT INTO "${logInfo.schemaName}"."${logInfo.tableName}"
177
120
  (entity_id, actor_id, model, provider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status)
178
121
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
@@ -197,31 +140,29 @@ async function logInference(pool, pgSettings, logInfo, data) {
197
140
  }
198
141
  // ─── Route Handlers ─────────────────────────────────────────────────────────
199
142
  async function handleCreateThread(req, res, entityId) {
200
- if (!req.token?.user_id) {
143
+ const ctx = req.constructive;
144
+ if (!ctx?.userId) {
201
145
  res.status(401).json({ error: 'Authentication required' });
202
146
  return;
203
147
  }
204
- const dbname = req.api?.dbname;
205
- if (!dbname) {
148
+ if (!ctx.api.dbname) {
206
149
  res.status(400).json({ error: 'Database not resolved' });
207
150
  return;
208
151
  }
209
- const pool = getPgPool({ database: dbname });
210
- const discovery = await getAgentDiscovery(pool, dbname);
152
+ const discovery = await getAgentDiscovery(ctx.pool, ctx.api.dbname);
211
153
  if (!discovery?.thread) {
212
154
  res.status(404).json({ error: 'Agent module not provisioned for this database' });
213
155
  return;
214
156
  }
215
157
  const body = req.body || {};
216
158
  const { thread } = discovery;
217
- const pgSettings = getPgSettings(req);
218
- const result = await withRlsClient(pool, pgSettings, async (client) => {
159
+ const result = await ctx.withPgClient(async (client) => {
219
160
  const { rows } = await client.query(`INSERT INTO "${thread.schemaName}"."${thread.tableName}"
220
161
  (entity_id, owner_id, mode, model, system_prompt, title)
221
162
  VALUES ($1, $2, $3, $4, $5, $6)
222
163
  RETURNING id, mode, model, system_prompt, status, created_at`, [
223
164
  entityId,
224
- req.token.user_id,
165
+ ctx.userId,
225
166
  body.mode ?? 'ask',
226
167
  body.model ?? null,
227
168
  body.system_prompt ?? null,
@@ -239,17 +180,16 @@ async function handleCreateThread(req, res, entityId) {
239
180
  });
240
181
  }
241
182
  async function handleSendMessage(req, res, entityId) {
242
- if (!req.token?.user_id) {
183
+ const ctx = req.constructive;
184
+ if (!ctx?.userId) {
243
185
  res.status(401).json({ error: 'Authentication required' });
244
186
  return;
245
187
  }
246
- const dbname = req.api?.dbname;
247
- if (!dbname) {
188
+ if (!ctx.api.dbname) {
248
189
  res.status(400).json({ error: 'Database not resolved' });
249
190
  return;
250
191
  }
251
- const pool = getPgPool({ database: dbname });
252
- const discovery = await getAgentDiscovery(pool, dbname);
192
+ const discovery = await getAgentDiscovery(ctx.pool, ctx.api.dbname);
253
193
  if (!discovery?.thread || !discovery?.message) {
254
194
  res.status(404).json({ error: 'Agent module not provisioned for this database' });
255
195
  return;
@@ -260,12 +200,10 @@ async function handleSendMessage(req, res, entityId) {
260
200
  return;
261
201
  }
262
202
  const { thread, message: msgTable } = discovery;
263
- const pgSettings = getPgSettings(req);
264
203
  const threadId = req.params.thread_id;
265
- const userId = req.token.user_id;
266
- const databaseId = req.databaseId;
204
+ const userId = ctx.userId;
267
205
  // 1. Verify thread exists and user owns it (RLS enforced)
268
- const threadRow = await withRlsClient(pool, pgSettings, async (client) => {
206
+ const threadRow = await ctx.withPgClient(async (client) => {
269
207
  const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
270
208
  FROM "${thread.schemaName}"."${thread.tableName}"
271
209
  WHERE id = $1`, [threadId]);
@@ -276,11 +214,9 @@ async function handleSendMessage(req, res, entityId) {
276
214
  return;
277
215
  }
278
216
  // 2. Resolve billing config + inference log discovery
279
- const billing = databaseId
280
- ? await resolveBilling(pool, pgSettings, databaseId)
281
- : null;
282
- const inferenceLog = await getInferenceLogInfo(pool, dbname);
283
- const ollama = resolveOllamaClient();
217
+ const billing = await resolveBilling(ctx);
218
+ const inferenceLog = await getInferenceLogInfo(ctx.pool, ctx.api.dbname);
219
+ const ollama = resolveOllamaAdapter();
284
220
  if (!ollama) {
285
221
  res.status(503).json({ error: 'No LLM provider configured' });
286
222
  return;
@@ -288,7 +224,7 @@ async function handleSendMessage(req, res, entityId) {
288
224
  const model = body.model ?? threadRow.model ?? ollama.model;
289
225
  const meterSlug = model;
290
226
  if (billing) {
291
- const allowed = await checkQuota(pool, pgSettings, billing, entityId, meterSlug);
227
+ const allowed = await checkQuota(ctx, billing, entityId, meterSlug);
292
228
  if (!allowed) {
293
229
  res.status(429).json({
294
230
  error: 'Token quota exceeded',
@@ -299,7 +235,7 @@ async function handleSendMessage(req, res, entityId) {
299
235
  }
300
236
  }
301
237
  // 3. Persist user message(s)
302
- await withRlsClient(pool, pgSettings, async (client) => {
238
+ await ctx.withPgClient(async (client) => {
303
239
  for (const msg of body.messages) {
304
240
  if (msg.role === 'user') {
305
241
  await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
@@ -314,7 +250,7 @@ async function handleSendMessage(req, res, entityId) {
314
250
  }
315
251
  });
316
252
  // 4. Load full thread history for context
317
- const history = await withRlsClient(pool, pgSettings, async (client) => {
253
+ const history = await ctx.withPgClient(async (client) => {
318
254
  const { rows } = await client.query(`SELECT author_role, parts, created_at
319
255
  FROM "${msgTable.schemaName}"."${msgTable.tableName}"
320
256
  WHERE thread_id = $1
@@ -343,7 +279,7 @@ async function handleSendMessage(req, res, entityId) {
343
279
  const shouldStream = body.stream !== false;
344
280
  const startTime = Date.now();
345
281
  if (shouldStream) {
346
- // ── SSE Streaming ──────────────────────────────────────────────────
282
+ // ── SSE Streaming via OllamaAdapter ─────────────────────────────────
347
283
  res.writeHead(200, {
348
284
  'Content-Type': 'text/event-stream',
349
285
  'Cache-Control': 'no-cache',
@@ -352,38 +288,55 @@ async function handleSendMessage(req, res, entityId) {
352
288
  });
353
289
  const messageId = `msg_${Date.now()}`;
354
290
  try {
355
- let streamedContent = '';
356
- const result = await callWithUsage(ollama.client, {
357
- model,
358
- messages: llmMessages,
359
- stream: true,
291
+ const systemMsg = llmMessages.find(m => m.role === 'system');
292
+ const nonSystem = llmMessages.filter(m => m.role !== 'system');
293
+ const modelDesc = ollama.adapter.createModel(model, {
294
+ maxOutputTokens: undefined,
295
+ });
296
+ const context = {
297
+ systemPrompt: systemMsg?.content,
298
+ messages: nonSystem.map((m) => ({
299
+ role: m.role,
300
+ content: m.content,
301
+ timestamp: Date.now(),
302
+ })),
303
+ };
304
+ const stream = ollama.adapter.stream(modelDesc, context, {
360
305
  temperature: body.temperature,
361
- }, (chunk) => {
362
- streamedContent += chunk;
363
- const event = {
364
- id: messageId,
365
- choices: [{
366
- index: 0,
367
- delta: { content: chunk, role: 'assistant' },
368
- finish_reason: null,
369
- }],
370
- model,
371
- };
372
- res.write(`data: ${JSON.stringify(event)}\n\n`);
373
306
  });
374
- // Streaming generate() returns void; use accumulated chunks
307
+ let streamedContent = '';
308
+ for await (const event of stream) {
309
+ if (event.type === 'text_delta') {
310
+ streamedContent += event.delta;
311
+ const sseEvent = {
312
+ id: messageId,
313
+ choices: [{
314
+ index: 0,
315
+ delta: { content: event.delta, role: 'assistant' },
316
+ finish_reason: null,
317
+ }],
318
+ model,
319
+ };
320
+ res.write(`data: ${JSON.stringify(sseEvent)}\n\n`);
321
+ }
322
+ }
323
+ const result = await stream.result();
375
324
  const content = streamedContent;
376
- const promptText = llmMessages.map(m => m.content).join(' ');
377
325
  const latencyMs = Date.now() - startTime;
378
- const inputTokens = placeholderAmountTokens(promptText);
379
- const outputTokens = placeholderAmountTokens(content);
380
- const totalTokens = inputTokens + outputTokens;
326
+ const usage = {
327
+ input: result.usage.input,
328
+ output: result.usage.output,
329
+ reasoning: result.usage.reasoning,
330
+ cacheRead: result.usage.cacheRead,
331
+ cacheWrite: result.usage.cacheWrite,
332
+ totalTokens: result.usage.totalTokens,
333
+ };
381
334
  // Send [DONE] marker
382
335
  res.write('data: [DONE]\n\n');
383
336
  res.end();
384
337
  // 6. Persist assistant message with model (fire-and-forget)
385
338
  if (content) {
386
- withRlsClient(pool, pgSettings, async (client) => {
339
+ ctx.withPgClient(async (client) => {
387
340
  await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
388
341
  (thread_id, owner_id, entity_id, author_role, parts, model)
389
342
  VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
@@ -398,10 +351,12 @@ async function handleSendMessage(req, res, entityId) {
398
351
  });
399
352
  }
400
353
  // 7. Record billing usage (fire-and-forget)
401
- if (billing && totalTokens > 0) {
402
- recordUsage(pool, pgSettings, billing, entityId, meterSlug, totalTokens, {
403
- input_tokens: inputTokens,
404
- output_tokens: outputTokens,
354
+ if (billing && usage.totalTokens > 0) {
355
+ recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
356
+ input_tokens: usage.input,
357
+ output_tokens: usage.output,
358
+ cache_read_tokens: usage.cacheRead,
359
+ cache_write_tokens: usage.cacheWrite,
405
360
  model,
406
361
  latency_ms: latencyMs,
407
362
  stream: true,
@@ -409,16 +364,16 @@ async function handleSendMessage(req, res, entityId) {
409
364
  }
410
365
  // 8. Inference log (fire-and-forget)
411
366
  if (inferenceLog) {
412
- logInference(pool, pgSettings, inferenceLog, {
367
+ logInference(ctx, inferenceLog, {
413
368
  entityId,
414
369
  actorId: userId,
415
370
  model,
416
371
  provider: 'ollama',
417
372
  service: 'llm',
418
373
  operation: 'chat',
419
- inputTokens,
420
- outputTokens,
421
- totalTokens,
374
+ inputTokens: usage.input,
375
+ outputTokens: usage.output,
376
+ totalTokens: usage.totalTokens,
422
377
  latencyMs,
423
378
  status: 'ok',
424
379
  }).catch(() => { });
@@ -433,34 +388,56 @@ async function handleSendMessage(req, res, entityId) {
433
388
  }
434
389
  }
435
390
  else {
436
- // ── Non-streaming (batch) ──────────────────────────────────────────
437
- const result = await callWithUsage(ollama.client, {
438
- model,
439
- messages: llmMessages,
440
- stream: false,
391
+ // ── Non-streaming (batch) via OllamaAdapter ─────────────────────────
392
+ const systemMsg = llmMessages.find(m => m.role === 'system');
393
+ const nonSystem = llmMessages.filter(m => m.role !== 'system');
394
+ const modelDesc = ollama.adapter.createModel(model, {
395
+ maxOutputTokens: undefined,
396
+ });
397
+ const context = {
398
+ systemPrompt: systemMsg?.content,
399
+ messages: nonSystem.map((m) => ({
400
+ role: m.role,
401
+ content: m.content,
402
+ timestamp: Date.now(),
403
+ })),
404
+ };
405
+ const stream = ollama.adapter.stream(modelDesc, context, {
441
406
  temperature: body.temperature,
442
407
  });
408
+ const result = await stream.result();
409
+ const content = result.content
410
+ .filter((block) => block.type === 'text')
411
+ .map((block) => block.text)
412
+ .join('');
443
413
  const latencyMs = Date.now() - startTime;
444
- const inputTokens = result.usage.input;
445
- const outputTokens = result.usage.output;
446
- const totalTokens = result.usage.totalTokens;
414
+ const usage = {
415
+ input: result.usage.input,
416
+ output: result.usage.output,
417
+ reasoning: result.usage.reasoning,
418
+ cacheRead: result.usage.cacheRead,
419
+ cacheWrite: result.usage.cacheWrite,
420
+ totalTokens: result.usage.totalTokens,
421
+ };
447
422
  // Persist assistant message with model
448
- await withRlsClient(pool, pgSettings, async (client) => {
423
+ await ctx.withPgClient(async (client) => {
449
424
  await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
450
425
  (thread_id, owner_id, entity_id, author_role, parts, model)
451
426
  VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
452
427
  threadId,
453
428
  userId,
454
429
  'assistant',
455
- JSON.stringify([{ type: 'text', text: result.content }]),
430
+ JSON.stringify([{ type: 'text', text: content }]),
456
431
  model,
457
432
  ]);
458
433
  });
459
434
  // Record billing usage
460
- if (billing && totalTokens > 0) {
461
- recordUsage(pool, pgSettings, billing, entityId, meterSlug, totalTokens, {
462
- input_tokens: inputTokens,
463
- output_tokens: outputTokens,
435
+ if (billing && usage.totalTokens > 0) {
436
+ recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
437
+ input_tokens: usage.input,
438
+ output_tokens: usage.output,
439
+ cache_read_tokens: usage.cacheRead,
440
+ cache_write_tokens: usage.cacheWrite,
464
441
  model,
465
442
  latency_ms: latencyMs,
466
443
  stream: false,
@@ -468,16 +445,16 @@ async function handleSendMessage(req, res, entityId) {
468
445
  }
469
446
  // Inference log
470
447
  if (inferenceLog) {
471
- logInference(pool, pgSettings, inferenceLog, {
448
+ logInference(ctx, inferenceLog, {
472
449
  entityId,
473
450
  actorId: userId,
474
451
  model,
475
452
  provider: 'ollama',
476
453
  service: 'llm',
477
454
  operation: 'chat',
478
- inputTokens,
479
- outputTokens,
480
- totalTokens,
455
+ inputTokens: usage.input,
456
+ outputTokens: usage.output,
457
+ totalTokens: usage.totalTokens,
481
458
  latencyMs,
482
459
  status: 'ok',
483
460
  }).catch(() => { });
@@ -486,14 +463,14 @@ async function handleSendMessage(req, res, entityId) {
486
463
  id: `msg_${Date.now()}`,
487
464
  choices: [{
488
465
  index: 0,
489
- message: { role: 'assistant', content: result.content },
466
+ message: { role: 'assistant', content },
490
467
  finish_reason: 'stop',
491
468
  }],
492
469
  model,
493
470
  usage: {
494
- prompt_tokens: inputTokens,
495
- completion_tokens: outputTokens,
496
- total_tokens: totalTokens,
471
+ prompt_tokens: usage.input,
472
+ completion_tokens: usage.output,
473
+ total_tokens: usage.totalTokens,
497
474
  },
498
475
  });
499
476
  }
@@ -528,7 +505,7 @@ export function createLlmApiRouter() {
528
505
  // ── Global routes (no entity_id — bills to actor_id from JWT) ────────────
529
506
  router.post('/v1/threads', async (req, res) => {
530
507
  try {
531
- const userId = req.token?.user_id;
508
+ const userId = req.constructive?.userId;
532
509
  if (!userId) {
533
510
  res.status(401).json({ error: 'Authentication required' });
534
511
  return;
@@ -544,7 +521,7 @@ export function createLlmApiRouter() {
544
521
  });
545
522
  router.post('/v1/threads/:thread_id/messages', async (req, res) => {
546
523
  try {
547
- const userId = req.token?.user_id;
524
+ const userId = req.constructive?.userId;
548
525
  if (!userId) {
549
526
  res.status(401).json({ error: 'Authentication required' });
550
527
  return;
package/esm/server.js CHANGED
@@ -28,6 +28,7 @@ import { createCaptchaMiddleware } from './middleware/captcha';
28
28
  import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
29
29
  import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
30
30
  import { createLlmApiRouter } from './middleware/llm-api';
31
+ import { createContextMiddleware, requestIdMiddleware } from '@constructive-io/express-context';
31
32
  import { startDebugSampler } from './diagnostics/debug-sampler';
32
33
  const log = new Logger('server');
33
34
  /**
@@ -135,10 +136,12 @@ class Server {
135
136
  app.use('/graphql', multipartBridge);
136
137
  app.use(parseDomains());
137
138
  app.use(requestIp.mw());
139
+ app.use(requestIdMiddleware());
138
140
  app.use(requestLogger);
139
141
  app.use(api);
140
142
  app.post('/upload', uploadAuthenticate, ...uploadRoute);
141
143
  app.use(authenticate);
144
+ app.use(createContextMiddleware({ pg: effectiveOpts.pg }));
142
145
  app.use(createCaptchaMiddleware());
143
146
  // CSRF protection for cookie-authenticated requests
144
147
  // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests
@@ -19,6 +19,9 @@
19
19
  *
20
20
  * Auth: JWT from the auth middleware (req.token) → pg SET LOCAL context for RLS
21
21
  * Metering: check_billing_quota → LLM call → record_usage with real token counts
22
+ *
23
+ * Context: Uses `req.constructive` from @constructive-io/express-context
24
+ * for tenant-scoped database access, pgSettings, and withPgClient.
22
25
  */
23
26
  import { Router } from 'express';
24
27
  export declare function createLlmApiRouter(): Router;
@@ -20,6 +20,9 @@
20
20
  *
21
21
  * Auth: JWT from the auth middleware (req.token) → pg SET LOCAL context for RLS
22
22
  * Metering: check_billing_quota → LLM call → record_usage with real token counts
23
+ *
24
+ * Context: Uses `req.constructive` from @constructive-io/express-context
25
+ * for tenant-scoped database access, pgSettings, and withPgClient.
23
26
  */
24
27
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
25
28
  if (k2 === undefined) k2 = k;
@@ -54,95 +57,30 @@ var __importStar = (this && this.__importStar) || (function () {
54
57
  return result;
55
58
  };
56
59
  })();
57
- var __importDefault = (this && this.__importDefault) || function (mod) {
58
- return (mod && mod.__esModule) ? mod : { "default": mod };
59
- };
60
60
  Object.defineProperty(exports, "__esModule", { value: true });
61
61
  exports.createLlmApiRouter = createLlmApiRouter;
62
62
  const express_1 = __importStar(require("express"));
63
63
  const logger_1 = require("@pgpmjs/logger");
64
- const pg_cache_1 = require("pg-cache");
65
- const ollama_1 = __importDefault(require("@agentic-kit/ollama"));
64
+ const ollama_1 = require("@agentic-kit/ollama");
66
65
  const graphile_cache_1 = require("graphile-cache");
67
66
  const graphile_llm_1 = require("graphile-llm");
68
67
  const log = new logger_1.Logger('llm-api');
69
- /**
70
- * Placeholder: replace with actual provider token counts once generateWithUsage() is approved.
71
- * Estimates ~4 chars per token for English text.
72
- */
73
- function placeholderAmountTokens(text) {
74
- return Math.ceil(text.length / 4);
75
- }
76
- /**
77
- * Call generate() and estimate token counts from text length.
78
- * When a provider-native token counting API is approved, swap the
79
- * estimation logic here without changing call sites.
80
- */
81
- async function callWithUsage(client, input, onChunk) {
82
- const promptText = input.messages.map((m) => m.content).join(' ');
83
- let content;
84
- if (onChunk || input.stream) {
85
- await client.generate(input, onChunk);
86
- content = '';
87
- }
88
- else {
89
- content = await client.generate(input);
90
- }
91
- const inputTokens = placeholderAmountTokens(promptText);
92
- const outputTokens = placeholderAmountTokens(content);
93
- return {
94
- content,
95
- usage: { input: inputTokens, output: outputTokens, totalTokens: inputTokens + outputTokens },
96
- };
97
- }
98
68
  // ─── Helpers ────────────────────────────────────────────────────────────────
99
- function getPgSettings(req) {
100
- const settings = {};
101
- if (req.token?.user_id) {
102
- settings['jwt.claims.user_id'] = req.token.user_id;
103
- settings['role'] = 'authenticated';
104
- }
105
- if (req.databaseId) {
106
- settings['jwt.claims.database_id'] = req.databaseId;
107
- }
108
- if (req.requestId) {
109
- settings['request.id'] = req.requestId;
110
- }
111
- return settings;
112
- }
113
- async function withRlsClient(pool, pgSettings, fn) {
114
- const client = await pool.connect();
115
- try {
116
- await client.query('BEGIN');
117
- for (const [key, value] of Object.entries(pgSettings)) {
118
- await client.query('SELECT set_config($1, $2, true)', [key, value]);
119
- }
120
- const result = await fn(client);
121
- await client.query('COMMIT');
122
- return result;
123
- }
124
- catch (err) {
125
- await client.query('ROLLBACK').catch(() => { });
126
- throw err;
127
- }
128
- finally {
129
- client.release();
130
- }
131
- }
132
- function resolveOllamaClient() {
69
+ function resolveOllamaAdapter() {
133
70
  const { chat } = (0, graphile_llm_1.getLlmEnvOptions)();
134
71
  if (chat.provider === 'ollama') {
135
72
  return {
136
- client: new ollama_1.default(chat.baseUrl),
73
+ adapter: new ollama_1.OllamaAdapter(chat.baseUrl),
137
74
  model: chat.model,
75
+ baseUrl: chat.baseUrl,
138
76
  };
139
77
  }
140
78
  return null;
141
79
  }
142
80
  // ─── Billing Helpers ────────────────────────────────────────────────────────
143
- async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
81
+ async function checkQuota(ctx, billing, entityId, meterSlug) {
144
82
  try {
145
- return await withRlsClient(pool, pgSettings, async (client) => {
83
+ return await ctx.withPgClient(async (client) => {
146
84
  const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
147
85
  const result = await client.query(sql, [meterSlug, entityId, 1]);
148
86
  return result.rows[0]?.allowed !== false;
@@ -154,9 +92,9 @@ async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
154
92
  return true;
155
93
  }
156
94
  }
157
- async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amount, metadata) {
95
+ async function recordUsage(ctx, billing, entityId, meterSlug, amount, metadata) {
158
96
  try {
159
- await withRlsClient(pool, pgSettings, async (client) => {
97
+ await ctx.withPgClient(async (client) => {
160
98
  const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
161
99
  await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
162
100
  });
@@ -166,11 +104,13 @@ async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amoun
166
104
  log.warn(`[llm-api] record_usage failed (non-fatal): ${message}`);
167
105
  }
168
106
  }
169
- async function resolveBilling(pool, pgSettings, databaseId) {
107
+ async function resolveBilling(ctx) {
108
+ if (!ctx.databaseId)
109
+ return null;
170
110
  try {
171
111
  let billing = null;
172
- await withRlsClient(pool, pgSettings, async (client) => {
173
- const entry = await (0, graphile_llm_1.getLlmBillingConfig)(client, databaseId);
112
+ await ctx.withPgClient(async (client) => {
113
+ const entry = await (0, graphile_llm_1.getLlmBillingConfig)(client, ctx.databaseId);
174
114
  billing = entry.billing;
175
115
  });
176
116
  return billing;
@@ -209,9 +149,9 @@ async function getInferenceLogInfo(pool, dbname) {
209
149
  inferenceLogCache.set(dbname, info);
210
150
  return info;
211
151
  }
212
- async function logInference(pool, pgSettings, logInfo, data) {
152
+ async function logInference(ctx, logInfo, data) {
213
153
  try {
214
- await withRlsClient(pool, pgSettings, async (client) => {
154
+ await ctx.withPgClient(async (client) => {
215
155
  await client.query(`INSERT INTO "${logInfo.schemaName}"."${logInfo.tableName}"
216
156
  (entity_id, actor_id, model, provider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status)
217
157
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
@@ -236,31 +176,29 @@ async function logInference(pool, pgSettings, logInfo, data) {
236
176
  }
237
177
  // ─── Route Handlers ─────────────────────────────────────────────────────────
238
178
  async function handleCreateThread(req, res, entityId) {
239
- if (!req.token?.user_id) {
179
+ const ctx = req.constructive;
180
+ if (!ctx?.userId) {
240
181
  res.status(401).json({ error: 'Authentication required' });
241
182
  return;
242
183
  }
243
- const dbname = req.api?.dbname;
244
- if (!dbname) {
184
+ if (!ctx.api.dbname) {
245
185
  res.status(400).json({ error: 'Database not resolved' });
246
186
  return;
247
187
  }
248
- const pool = (0, pg_cache_1.getPgPool)({ database: dbname });
249
- const discovery = await (0, graphile_llm_1.getAgentDiscovery)(pool, dbname);
188
+ const discovery = await (0, graphile_llm_1.getAgentDiscovery)(ctx.pool, ctx.api.dbname);
250
189
  if (!discovery?.thread) {
251
190
  res.status(404).json({ error: 'Agent module not provisioned for this database' });
252
191
  return;
253
192
  }
254
193
  const body = req.body || {};
255
194
  const { thread } = discovery;
256
- const pgSettings = getPgSettings(req);
257
- const result = await withRlsClient(pool, pgSettings, async (client) => {
195
+ const result = await ctx.withPgClient(async (client) => {
258
196
  const { rows } = await client.query(`INSERT INTO "${thread.schemaName}"."${thread.tableName}"
259
197
  (entity_id, owner_id, mode, model, system_prompt, title)
260
198
  VALUES ($1, $2, $3, $4, $5, $6)
261
199
  RETURNING id, mode, model, system_prompt, status, created_at`, [
262
200
  entityId,
263
- req.token.user_id,
201
+ ctx.userId,
264
202
  body.mode ?? 'ask',
265
203
  body.model ?? null,
266
204
  body.system_prompt ?? null,
@@ -278,17 +216,16 @@ async function handleCreateThread(req, res, entityId) {
278
216
  });
279
217
  }
280
218
  async function handleSendMessage(req, res, entityId) {
281
- if (!req.token?.user_id) {
219
+ const ctx = req.constructive;
220
+ if (!ctx?.userId) {
282
221
  res.status(401).json({ error: 'Authentication required' });
283
222
  return;
284
223
  }
285
- const dbname = req.api?.dbname;
286
- if (!dbname) {
224
+ if (!ctx.api.dbname) {
287
225
  res.status(400).json({ error: 'Database not resolved' });
288
226
  return;
289
227
  }
290
- const pool = (0, pg_cache_1.getPgPool)({ database: dbname });
291
- const discovery = await (0, graphile_llm_1.getAgentDiscovery)(pool, dbname);
228
+ const discovery = await (0, graphile_llm_1.getAgentDiscovery)(ctx.pool, ctx.api.dbname);
292
229
  if (!discovery?.thread || !discovery?.message) {
293
230
  res.status(404).json({ error: 'Agent module not provisioned for this database' });
294
231
  return;
@@ -299,12 +236,10 @@ async function handleSendMessage(req, res, entityId) {
299
236
  return;
300
237
  }
301
238
  const { thread, message: msgTable } = discovery;
302
- const pgSettings = getPgSettings(req);
303
239
  const threadId = req.params.thread_id;
304
- const userId = req.token.user_id;
305
- const databaseId = req.databaseId;
240
+ const userId = ctx.userId;
306
241
  // 1. Verify thread exists and user owns it (RLS enforced)
307
- const threadRow = await withRlsClient(pool, pgSettings, async (client) => {
242
+ const threadRow = await ctx.withPgClient(async (client) => {
308
243
  const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
309
244
  FROM "${thread.schemaName}"."${thread.tableName}"
310
245
  WHERE id = $1`, [threadId]);
@@ -315,11 +250,9 @@ async function handleSendMessage(req, res, entityId) {
315
250
  return;
316
251
  }
317
252
  // 2. Resolve billing config + inference log discovery
318
- const billing = databaseId
319
- ? await resolveBilling(pool, pgSettings, databaseId)
320
- : null;
321
- const inferenceLog = await getInferenceLogInfo(pool, dbname);
322
- const ollama = resolveOllamaClient();
253
+ const billing = await resolveBilling(ctx);
254
+ const inferenceLog = await getInferenceLogInfo(ctx.pool, ctx.api.dbname);
255
+ const ollama = resolveOllamaAdapter();
323
256
  if (!ollama) {
324
257
  res.status(503).json({ error: 'No LLM provider configured' });
325
258
  return;
@@ -327,7 +260,7 @@ async function handleSendMessage(req, res, entityId) {
327
260
  const model = body.model ?? threadRow.model ?? ollama.model;
328
261
  const meterSlug = model;
329
262
  if (billing) {
330
- const allowed = await checkQuota(pool, pgSettings, billing, entityId, meterSlug);
263
+ const allowed = await checkQuota(ctx, billing, entityId, meterSlug);
331
264
  if (!allowed) {
332
265
  res.status(429).json({
333
266
  error: 'Token quota exceeded',
@@ -338,7 +271,7 @@ async function handleSendMessage(req, res, entityId) {
338
271
  }
339
272
  }
340
273
  // 3. Persist user message(s)
341
- await withRlsClient(pool, pgSettings, async (client) => {
274
+ await ctx.withPgClient(async (client) => {
342
275
  for (const msg of body.messages) {
343
276
  if (msg.role === 'user') {
344
277
  await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
@@ -353,7 +286,7 @@ async function handleSendMessage(req, res, entityId) {
353
286
  }
354
287
  });
355
288
  // 4. Load full thread history for context
356
- const history = await withRlsClient(pool, pgSettings, async (client) => {
289
+ const history = await ctx.withPgClient(async (client) => {
357
290
  const { rows } = await client.query(`SELECT author_role, parts, created_at
358
291
  FROM "${msgTable.schemaName}"."${msgTable.tableName}"
359
292
  WHERE thread_id = $1
@@ -382,7 +315,7 @@ async function handleSendMessage(req, res, entityId) {
382
315
  const shouldStream = body.stream !== false;
383
316
  const startTime = Date.now();
384
317
  if (shouldStream) {
385
- // ── SSE Streaming ──────────────────────────────────────────────────
318
+ // ── SSE Streaming via OllamaAdapter ─────────────────────────────────
386
319
  res.writeHead(200, {
387
320
  'Content-Type': 'text/event-stream',
388
321
  'Cache-Control': 'no-cache',
@@ -391,38 +324,55 @@ async function handleSendMessage(req, res, entityId) {
391
324
  });
392
325
  const messageId = `msg_${Date.now()}`;
393
326
  try {
394
- let streamedContent = '';
395
- const result = await callWithUsage(ollama.client, {
396
- model,
397
- messages: llmMessages,
398
- stream: true,
327
+ const systemMsg = llmMessages.find(m => m.role === 'system');
328
+ const nonSystem = llmMessages.filter(m => m.role !== 'system');
329
+ const modelDesc = ollama.adapter.createModel(model, {
330
+ maxOutputTokens: undefined,
331
+ });
332
+ const context = {
333
+ systemPrompt: systemMsg?.content,
334
+ messages: nonSystem.map((m) => ({
335
+ role: m.role,
336
+ content: m.content,
337
+ timestamp: Date.now(),
338
+ })),
339
+ };
340
+ const stream = ollama.adapter.stream(modelDesc, context, {
399
341
  temperature: body.temperature,
400
- }, (chunk) => {
401
- streamedContent += chunk;
402
- const event = {
403
- id: messageId,
404
- choices: [{
405
- index: 0,
406
- delta: { content: chunk, role: 'assistant' },
407
- finish_reason: null,
408
- }],
409
- model,
410
- };
411
- res.write(`data: ${JSON.stringify(event)}\n\n`);
412
342
  });
413
- // Streaming generate() returns void; use accumulated chunks
343
+ let streamedContent = '';
344
+ for await (const event of stream) {
345
+ if (event.type === 'text_delta') {
346
+ streamedContent += event.delta;
347
+ const sseEvent = {
348
+ id: messageId,
349
+ choices: [{
350
+ index: 0,
351
+ delta: { content: event.delta, role: 'assistant' },
352
+ finish_reason: null,
353
+ }],
354
+ model,
355
+ };
356
+ res.write(`data: ${JSON.stringify(sseEvent)}\n\n`);
357
+ }
358
+ }
359
+ const result = await stream.result();
414
360
  const content = streamedContent;
415
- const promptText = llmMessages.map(m => m.content).join(' ');
416
361
  const latencyMs = Date.now() - startTime;
417
- const inputTokens = placeholderAmountTokens(promptText);
418
- const outputTokens = placeholderAmountTokens(content);
419
- const totalTokens = inputTokens + outputTokens;
362
+ const usage = {
363
+ input: result.usage.input,
364
+ output: result.usage.output,
365
+ reasoning: result.usage.reasoning,
366
+ cacheRead: result.usage.cacheRead,
367
+ cacheWrite: result.usage.cacheWrite,
368
+ totalTokens: result.usage.totalTokens,
369
+ };
420
370
  // Send [DONE] marker
421
371
  res.write('data: [DONE]\n\n');
422
372
  res.end();
423
373
  // 6. Persist assistant message with model (fire-and-forget)
424
374
  if (content) {
425
- withRlsClient(pool, pgSettings, async (client) => {
375
+ ctx.withPgClient(async (client) => {
426
376
  await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
427
377
  (thread_id, owner_id, entity_id, author_role, parts, model)
428
378
  VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
@@ -437,10 +387,12 @@ async function handleSendMessage(req, res, entityId) {
437
387
  });
438
388
  }
439
389
  // 7. Record billing usage (fire-and-forget)
440
- if (billing && totalTokens > 0) {
441
- recordUsage(pool, pgSettings, billing, entityId, meterSlug, totalTokens, {
442
- input_tokens: inputTokens,
443
- output_tokens: outputTokens,
390
+ if (billing && usage.totalTokens > 0) {
391
+ recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
392
+ input_tokens: usage.input,
393
+ output_tokens: usage.output,
394
+ cache_read_tokens: usage.cacheRead,
395
+ cache_write_tokens: usage.cacheWrite,
444
396
  model,
445
397
  latency_ms: latencyMs,
446
398
  stream: true,
@@ -448,16 +400,16 @@ async function handleSendMessage(req, res, entityId) {
448
400
  }
449
401
  // 8. Inference log (fire-and-forget)
450
402
  if (inferenceLog) {
451
- logInference(pool, pgSettings, inferenceLog, {
403
+ logInference(ctx, inferenceLog, {
452
404
  entityId,
453
405
  actorId: userId,
454
406
  model,
455
407
  provider: 'ollama',
456
408
  service: 'llm',
457
409
  operation: 'chat',
458
- inputTokens,
459
- outputTokens,
460
- totalTokens,
410
+ inputTokens: usage.input,
411
+ outputTokens: usage.output,
412
+ totalTokens: usage.totalTokens,
461
413
  latencyMs,
462
414
  status: 'ok',
463
415
  }).catch(() => { });
@@ -472,34 +424,56 @@ async function handleSendMessage(req, res, entityId) {
472
424
  }
473
425
  }
474
426
  else {
475
- // ── Non-streaming (batch) ──────────────────────────────────────────
476
- const result = await callWithUsage(ollama.client, {
477
- model,
478
- messages: llmMessages,
479
- stream: false,
427
+ // ── Non-streaming (batch) via OllamaAdapter ─────────────────────────
428
+ const systemMsg = llmMessages.find(m => m.role === 'system');
429
+ const nonSystem = llmMessages.filter(m => m.role !== 'system');
430
+ const modelDesc = ollama.adapter.createModel(model, {
431
+ maxOutputTokens: undefined,
432
+ });
433
+ const context = {
434
+ systemPrompt: systemMsg?.content,
435
+ messages: nonSystem.map((m) => ({
436
+ role: m.role,
437
+ content: m.content,
438
+ timestamp: Date.now(),
439
+ })),
440
+ };
441
+ const stream = ollama.adapter.stream(modelDesc, context, {
480
442
  temperature: body.temperature,
481
443
  });
444
+ const result = await stream.result();
445
+ const content = result.content
446
+ .filter((block) => block.type === 'text')
447
+ .map((block) => block.text)
448
+ .join('');
482
449
  const latencyMs = Date.now() - startTime;
483
- const inputTokens = result.usage.input;
484
- const outputTokens = result.usage.output;
485
- const totalTokens = result.usage.totalTokens;
450
+ const usage = {
451
+ input: result.usage.input,
452
+ output: result.usage.output,
453
+ reasoning: result.usage.reasoning,
454
+ cacheRead: result.usage.cacheRead,
455
+ cacheWrite: result.usage.cacheWrite,
456
+ totalTokens: result.usage.totalTokens,
457
+ };
486
458
  // Persist assistant message with model
487
- await withRlsClient(pool, pgSettings, async (client) => {
459
+ await ctx.withPgClient(async (client) => {
488
460
  await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
489
461
  (thread_id, owner_id, entity_id, author_role, parts, model)
490
462
  VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
491
463
  threadId,
492
464
  userId,
493
465
  'assistant',
494
- JSON.stringify([{ type: 'text', text: result.content }]),
466
+ JSON.stringify([{ type: 'text', text: content }]),
495
467
  model,
496
468
  ]);
497
469
  });
498
470
  // Record billing usage
499
- if (billing && totalTokens > 0) {
500
- recordUsage(pool, pgSettings, billing, entityId, meterSlug, totalTokens, {
501
- input_tokens: inputTokens,
502
- output_tokens: outputTokens,
471
+ if (billing && usage.totalTokens > 0) {
472
+ recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
473
+ input_tokens: usage.input,
474
+ output_tokens: usage.output,
475
+ cache_read_tokens: usage.cacheRead,
476
+ cache_write_tokens: usage.cacheWrite,
503
477
  model,
504
478
  latency_ms: latencyMs,
505
479
  stream: false,
@@ -507,16 +481,16 @@ async function handleSendMessage(req, res, entityId) {
507
481
  }
508
482
  // Inference log
509
483
  if (inferenceLog) {
510
- logInference(pool, pgSettings, inferenceLog, {
484
+ logInference(ctx, inferenceLog, {
511
485
  entityId,
512
486
  actorId: userId,
513
487
  model,
514
488
  provider: 'ollama',
515
489
  service: 'llm',
516
490
  operation: 'chat',
517
- inputTokens,
518
- outputTokens,
519
- totalTokens,
491
+ inputTokens: usage.input,
492
+ outputTokens: usage.output,
493
+ totalTokens: usage.totalTokens,
520
494
  latencyMs,
521
495
  status: 'ok',
522
496
  }).catch(() => { });
@@ -525,14 +499,14 @@ async function handleSendMessage(req, res, entityId) {
525
499
  id: `msg_${Date.now()}`,
526
500
  choices: [{
527
501
  index: 0,
528
- message: { role: 'assistant', content: result.content },
502
+ message: { role: 'assistant', content },
529
503
  finish_reason: 'stop',
530
504
  }],
531
505
  model,
532
506
  usage: {
533
- prompt_tokens: inputTokens,
534
- completion_tokens: outputTokens,
535
- total_tokens: totalTokens,
507
+ prompt_tokens: usage.input,
508
+ completion_tokens: usage.output,
509
+ total_tokens: usage.totalTokens,
536
510
  },
537
511
  });
538
512
  }
@@ -567,7 +541,7 @@ function createLlmApiRouter() {
567
541
  // ── Global routes (no entity_id — bills to actor_id from JWT) ────────────
568
542
  router.post('/v1/threads', async (req, res) => {
569
543
  try {
570
- const userId = req.token?.user_id;
544
+ const userId = req.constructive?.userId;
571
545
  if (!userId) {
572
546
  res.status(401).json({ error: 'Authentication required' });
573
547
  return;
@@ -583,7 +557,7 @@ function createLlmApiRouter() {
583
557
  });
584
558
  router.post('/v1/threads/:thread_id/messages', async (req, res) => {
585
559
  try {
586
- const userId = req.token?.user_id;
560
+ const userId = req.constructive?.userId;
587
561
  if (!userId) {
588
562
  res.status(401).json({ error: 'Authentication required' });
589
563
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "4.32.0",
3
+ "version": "4.34.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -41,18 +41,19 @@
41
41
  "backend"
42
42
  ],
43
43
  "dependencies": {
44
- "@agentic-kit/ollama": "^1.2.1",
44
+ "@agentic-kit/ollama": "^2.0.0",
45
45
  "@constructive-io/csrf": "^0.14.0",
46
- "@constructive-io/graphql-env": "^3.11.1",
47
- "@constructive-io/graphql-types": "^3.10.1",
46
+ "@constructive-io/express-context": "^0.2.0",
47
+ "@constructive-io/graphql-env": "^3.12.0",
48
+ "@constructive-io/graphql-types": "^3.11.0",
48
49
  "@constructive-io/s3-utils": "^2.17.1",
49
50
  "@constructive-io/upload-names": "^2.16.0",
50
51
  "@constructive-io/url-domains": "^2.16.0",
51
52
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
52
- "@pgpmjs/env": "^2.23.0",
53
+ "@pgpmjs/env": "^2.24.0",
53
54
  "@pgpmjs/logger": "^2.11.0",
54
- "@pgpmjs/server-utils": "^3.11.0",
55
- "@pgpmjs/types": "^2.27.0",
55
+ "@pgpmjs/server-utils": "^3.12.0",
56
+ "@pgpmjs/types": "^2.28.0",
56
57
  "@pgsql/quotes": "^17.1.0",
57
58
  "cors": "^2.8.6",
58
59
  "deepmerge": "^4.3.1",
@@ -62,19 +63,19 @@
62
63
  "grafserv": "1.0.0",
63
64
  "graphile-build": "5.0.2",
64
65
  "graphile-build-pg": "5.0.2",
65
- "graphile-cache": "^3.11.2",
66
+ "graphile-cache": "^3.12.0",
66
67
  "graphile-config": "1.0.1",
67
- "graphile-llm": "^0.8.0",
68
- "graphile-settings": "^5.2.4",
68
+ "graphile-llm": "^0.10.0",
69
+ "graphile-settings": "^5.3.0",
69
70
  "graphile-utils": "5.0.1",
70
71
  "graphql": "16.13.0",
71
72
  "graphql-upload": "^13.0.0",
72
73
  "lru-cache": "^11.2.7",
73
74
  "multer": "^2.1.1",
74
75
  "pg": "^8.21.0",
75
- "pg-cache": "^3.10.1",
76
- "pg-env": "^1.14.0",
77
- "pg-query-context": "^2.15.1",
76
+ "pg-cache": "^3.11.0",
77
+ "pg-env": "^1.15.0",
78
+ "pg-query-context": "^2.16.0",
78
79
  "pg-sql2": "5.0.1",
79
80
  "postgraphile": "5.0.3",
80
81
  "request-ip": "^3.3.0"
@@ -89,10 +90,10 @@
89
90
  "@types/pg": "^8.20.0",
90
91
  "@types/request-ip": "^0.0.41",
91
92
  "cookie-parser": "^1.4.7",
92
- "graphile-test": "4.15.3",
93
+ "graphile-test": "4.16.0",
93
94
  "makage": "^0.3.0",
94
95
  "nodemon": "^3.1.14",
95
96
  "ts-node": "^10.9.2"
96
97
  },
97
- "gitHead": "030e1144acbd4e288ee74eff2ac0021ca0382ef7"
98
+ "gitHead": "c0d04574f7719d92e67becb58d60791ae978c5f5"
98
99
  }
package/server.js CHANGED
@@ -34,6 +34,7 @@ const captcha_1 = require("./middleware/captcha");
34
34
  const cookie_1 = require("./middleware/cookie");
35
35
  const upload_1 = require("./middleware/upload");
36
36
  const llm_api_1 = require("./middleware/llm-api");
37
+ const express_context_1 = require("@constructive-io/express-context");
37
38
  const debug_sampler_1 = require("./diagnostics/debug-sampler");
38
39
  const log = new logger_1.Logger('server');
39
40
  /**
@@ -142,10 +143,12 @@ class Server {
142
143
  app.use('/graphql', multipart_bridge_1.multipartBridge);
143
144
  app.use((0, url_domains_1.middleware)());
144
145
  app.use(request_ip_1.default.mw());
146
+ app.use((0, express_context_1.requestIdMiddleware)());
145
147
  app.use(requestLogger);
146
148
  app.use(api);
147
149
  app.post('/upload', uploadAuthenticate, ...upload_1.uploadRoute);
148
150
  app.use(authenticate);
151
+ app.use((0, express_context_1.createContextMiddleware)({ pg: effectiveOpts.pg }));
149
152
  app.use((0, captcha_1.createCaptchaMiddleware)());
150
153
  // CSRF protection for cookie-authenticated requests
151
154
  // Skip CSRF for Bearer token auth (not vulnerable to CSRF) and anonymous requests