@constructive-io/graphql-server 4.31.5 → 4.32.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.
@@ -245,14 +245,21 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
245
245
  if (req.token.access_level === 'read_only') {
246
246
  pgSettings['default_transaction_read_only'] = 'on';
247
247
  }
248
+ if (req.requestId) {
249
+ pgSettings['request.id'] = req.requestId;
250
+ }
248
251
  return { pgSettings };
249
252
  }
250
253
  }
254
+ const anonSettings = {
255
+ role: anonRole,
256
+ ...context,
257
+ };
258
+ if (req?.requestId) {
259
+ anonSettings['request.id'] = req.requestId;
260
+ }
251
261
  return {
252
- pgSettings: {
253
- role: anonRole,
254
- ...context,
255
- },
262
+ pgSettings: anonSettings,
256
263
  };
257
264
  },
258
265
  },
@@ -0,0 +1,562 @@
1
+ /**
2
+ * LLM API Router
3
+ *
4
+ * Express router providing REST streaming endpoints for AI agent conversations.
5
+ * Uses the agent tables (agent_thread, agent_message) discovered from the
6
+ * agent_chat_module config table at runtime.
7
+ *
8
+ * Hybrid architecture:
9
+ * - GraphQL handles CRUD (threads, messages, tasks) via PostGraphile
10
+ * - REST handles SSE streaming for chat completions (what GraphQL can't do)
11
+ *
12
+ * Routes (entity-scoped):
13
+ * POST /v1/orgs/:entity_id/threads → create thread
14
+ * POST /v1/orgs/:entity_id/threads/:thread_id/messages → send message + stream response
15
+ *
16
+ * Routes (global — bills to actor_id from JWT):
17
+ * POST /v1/threads → create thread (entity_id = user_id)
18
+ * POST /v1/threads/:thread_id/messages → send message + stream response
19
+ *
20
+ * Auth: JWT from the auth middleware (req.token) → pg SET LOCAL context for RLS
21
+ * Metering: check_billing_quota → LLM call → record_usage with real token counts
22
+ */
23
+ import express, { Router } from 'express';
24
+ import { Logger } from '@pgpmjs/logger';
25
+ import { getPgPool } from 'pg-cache';
26
+ import OllamaClient from '@agentic-kit/ollama';
27
+ import { ModuleConfigCache } from 'graphile-cache';
28
+ import { getLlmEnvOptions, getAgentDiscovery, getLlmBillingConfig, } from 'graphile-llm';
29
+ 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
+ // ─── 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() {
94
+ const { chat } = getLlmEnvOptions();
95
+ if (chat.provider === 'ollama') {
96
+ return {
97
+ client: new OllamaClient(chat.baseUrl),
98
+ model: chat.model,
99
+ };
100
+ }
101
+ return null;
102
+ }
103
+ // ─── Billing Helpers ────────────────────────────────────────────────────────
104
+ async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
105
+ try {
106
+ return await withRlsClient(pool, pgSettings, async (client) => {
107
+ const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
108
+ const result = await client.query(sql, [meterSlug, entityId, 1]);
109
+ return result.rows[0]?.allowed !== false;
110
+ });
111
+ }
112
+ catch (e) {
113
+ const message = e instanceof Error ? e.message : String(e);
114
+ log.warn(`[llm-api] check_billing_quota failed (allowing): ${message}`);
115
+ return true;
116
+ }
117
+ }
118
+ async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amount, metadata) {
119
+ try {
120
+ await withRlsClient(pool, pgSettings, async (client) => {
121
+ const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
122
+ await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
123
+ });
124
+ }
125
+ catch (e) {
126
+ const message = e instanceof Error ? e.message : String(e);
127
+ log.warn(`[llm-api] record_usage failed (non-fatal): ${message}`);
128
+ }
129
+ }
130
+ async function resolveBilling(pool, pgSettings, databaseId) {
131
+ try {
132
+ let billing = null;
133
+ await withRlsClient(pool, pgSettings, async (client) => {
134
+ const entry = await getLlmBillingConfig(client, databaseId);
135
+ billing = entry.billing;
136
+ });
137
+ return billing;
138
+ }
139
+ catch {
140
+ return null;
141
+ }
142
+ }
143
+ const INFERENCE_LOG_DISCOVERY_SQL = `
144
+ SELECT s.schema_name, ilm.inference_log_table_name
145
+ FROM metaschema_modules_public.inference_log_module ilm
146
+ JOIN metaschema_public.schema s ON s.id = ilm.schema_id
147
+ LIMIT 1
148
+ `;
149
+ const inferenceLogCache = new ModuleConfigCache({
150
+ name: 'inference-log',
151
+ ttlMs: 60_000,
152
+ });
153
+ async function getInferenceLogInfo(pool, dbname) {
154
+ const cached = inferenceLogCache.get(dbname);
155
+ if (cached !== undefined)
156
+ return cached;
157
+ let info = null;
158
+ try {
159
+ const { rows } = await pool.query(INFERENCE_LOG_DISCOVERY_SQL);
160
+ if (rows.length > 0) {
161
+ info = {
162
+ schemaName: rows[0].schema_name,
163
+ tableName: rows[0].inference_log_table_name,
164
+ };
165
+ }
166
+ }
167
+ catch {
168
+ // Module not provisioned
169
+ }
170
+ inferenceLogCache.set(dbname, info);
171
+ return info;
172
+ }
173
+ async function logInference(pool, pgSettings, logInfo, data) {
174
+ try {
175
+ await withRlsClient(pool, pgSettings, async (client) => {
176
+ await client.query(`INSERT INTO "${logInfo.schemaName}"."${logInfo.tableName}"
177
+ (entity_id, actor_id, model, provider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status)
178
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
179
+ data.entityId,
180
+ data.actorId,
181
+ data.model,
182
+ data.provider,
183
+ data.service,
184
+ data.operation,
185
+ data.inputTokens,
186
+ data.outputTokens,
187
+ data.totalTokens,
188
+ data.latencyMs,
189
+ data.status,
190
+ ]);
191
+ });
192
+ }
193
+ catch (e) {
194
+ const message = e instanceof Error ? e.message : String(e);
195
+ log.warn(`[llm-api] inference log INSERT failed (non-fatal): ${message}`);
196
+ }
197
+ }
198
+ // ─── Route Handlers ─────────────────────────────────────────────────────────
199
+ async function handleCreateThread(req, res, entityId) {
200
+ if (!req.token?.user_id) {
201
+ res.status(401).json({ error: 'Authentication required' });
202
+ return;
203
+ }
204
+ const dbname = req.api?.dbname;
205
+ if (!dbname) {
206
+ res.status(400).json({ error: 'Database not resolved' });
207
+ return;
208
+ }
209
+ const pool = getPgPool({ database: dbname });
210
+ const discovery = await getAgentDiscovery(pool, dbname);
211
+ if (!discovery?.thread) {
212
+ res.status(404).json({ error: 'Agent module not provisioned for this database' });
213
+ return;
214
+ }
215
+ const body = req.body || {};
216
+ const { thread } = discovery;
217
+ const pgSettings = getPgSettings(req);
218
+ const result = await withRlsClient(pool, pgSettings, async (client) => {
219
+ const { rows } = await client.query(`INSERT INTO "${thread.schemaName}"."${thread.tableName}"
220
+ (entity_id, owner_id, mode, model, system_prompt, title)
221
+ VALUES ($1, $2, $3, $4, $5, $6)
222
+ RETURNING id, mode, model, system_prompt, status, created_at`, [
223
+ entityId,
224
+ req.token.user_id,
225
+ body.mode ?? 'ask',
226
+ body.model ?? null,
227
+ body.system_prompt ?? null,
228
+ body.title ?? null,
229
+ ]);
230
+ return rows[0];
231
+ });
232
+ res.status(201).json({
233
+ id: result.id,
234
+ mode: result.mode,
235
+ model: result.model,
236
+ system_prompt: result.system_prompt,
237
+ status: result.status,
238
+ created_at: result.created_at,
239
+ });
240
+ }
241
+ async function handleSendMessage(req, res, entityId) {
242
+ if (!req.token?.user_id) {
243
+ res.status(401).json({ error: 'Authentication required' });
244
+ return;
245
+ }
246
+ const dbname = req.api?.dbname;
247
+ if (!dbname) {
248
+ res.status(400).json({ error: 'Database not resolved' });
249
+ return;
250
+ }
251
+ const pool = getPgPool({ database: dbname });
252
+ const discovery = await getAgentDiscovery(pool, dbname);
253
+ if (!discovery?.thread || !discovery?.message) {
254
+ res.status(404).json({ error: 'Agent module not provisioned for this database' });
255
+ return;
256
+ }
257
+ const body = req.body || {};
258
+ if (!body.messages?.length) {
259
+ res.status(400).json({ error: 'messages[] is required and must not be empty' });
260
+ return;
261
+ }
262
+ const { thread, message: msgTable } = discovery;
263
+ const pgSettings = getPgSettings(req);
264
+ const threadId = req.params.thread_id;
265
+ const userId = req.token.user_id;
266
+ const databaseId = req.databaseId;
267
+ // 1. Verify thread exists and user owns it (RLS enforced)
268
+ const threadRow = await withRlsClient(pool, pgSettings, async (client) => {
269
+ const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
270
+ FROM "${thread.schemaName}"."${thread.tableName}"
271
+ WHERE id = $1`, [threadId]);
272
+ return rows[0];
273
+ });
274
+ if (!threadRow) {
275
+ res.status(404).json({ error: 'Thread not found' });
276
+ return;
277
+ }
278
+ // 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();
284
+ if (!ollama) {
285
+ res.status(503).json({ error: 'No LLM provider configured' });
286
+ return;
287
+ }
288
+ const model = body.model ?? threadRow.model ?? ollama.model;
289
+ const meterSlug = model;
290
+ if (billing) {
291
+ const allowed = await checkQuota(pool, pgSettings, billing, entityId, meterSlug);
292
+ if (!allowed) {
293
+ res.status(429).json({
294
+ error: 'Token quota exceeded',
295
+ meter: meterSlug,
296
+ entity_id: entityId,
297
+ });
298
+ return;
299
+ }
300
+ }
301
+ // 3. Persist user message(s)
302
+ await withRlsClient(pool, pgSettings, async (client) => {
303
+ for (const msg of body.messages) {
304
+ if (msg.role === 'user') {
305
+ await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
306
+ (thread_id, owner_id, entity_id, author_role, parts)
307
+ VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4)`, [
308
+ threadId,
309
+ userId,
310
+ 'user',
311
+ JSON.stringify([{ type: 'text', text: msg.content }]),
312
+ ]);
313
+ }
314
+ }
315
+ });
316
+ // 4. Load full thread history for context
317
+ const history = await withRlsClient(pool, pgSettings, async (client) => {
318
+ const { rows } = await client.query(`SELECT author_role, parts, created_at
319
+ FROM "${msgTable.schemaName}"."${msgTable.tableName}"
320
+ WHERE thread_id = $1
321
+ ORDER BY created_at ASC`, [threadId]);
322
+ return rows;
323
+ });
324
+ const llmMessages = [];
325
+ const systemPrompt = threadRow.system_prompt;
326
+ if (systemPrompt) {
327
+ llmMessages.push({ role: 'system', content: systemPrompt });
328
+ }
329
+ for (const row of history) {
330
+ const parts = Array.isArray(row.parts) ? row.parts : [];
331
+ const textContent = parts
332
+ .filter((p) => p.type === 'text')
333
+ .map((p) => p.text)
334
+ .join('');
335
+ if (textContent) {
336
+ llmMessages.push({
337
+ role: row.author_role === 'user' ? 'user' : 'assistant',
338
+ content: textContent,
339
+ });
340
+ }
341
+ }
342
+ // 5. Call LLM with token usage tracking
343
+ const shouldStream = body.stream !== false;
344
+ const startTime = Date.now();
345
+ if (shouldStream) {
346
+ // ── SSE Streaming ──────────────────────────────────────────────────
347
+ res.writeHead(200, {
348
+ 'Content-Type': 'text/event-stream',
349
+ 'Cache-Control': 'no-cache',
350
+ 'Connection': 'keep-alive',
351
+ 'X-Accel-Buffering': 'no',
352
+ });
353
+ const messageId = `msg_${Date.now()}`;
354
+ try {
355
+ let streamedContent = '';
356
+ const result = await callWithUsage(ollama.client, {
357
+ model,
358
+ messages: llmMessages,
359
+ stream: true,
360
+ 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
+ });
374
+ // Streaming generate() returns void; use accumulated chunks
375
+ const content = streamedContent;
376
+ const promptText = llmMessages.map(m => m.content).join(' ');
377
+ const latencyMs = Date.now() - startTime;
378
+ const inputTokens = placeholderAmountTokens(promptText);
379
+ const outputTokens = placeholderAmountTokens(content);
380
+ const totalTokens = inputTokens + outputTokens;
381
+ // Send [DONE] marker
382
+ res.write('data: [DONE]\n\n');
383
+ res.end();
384
+ // 6. Persist assistant message with model (fire-and-forget)
385
+ if (content) {
386
+ withRlsClient(pool, pgSettings, async (client) => {
387
+ await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
388
+ (thread_id, owner_id, entity_id, author_role, parts, model)
389
+ VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
390
+ threadId,
391
+ userId,
392
+ 'assistant',
393
+ JSON.stringify([{ type: 'text', text: content }]),
394
+ model,
395
+ ]);
396
+ }).catch((err) => {
397
+ log.error('[llm-api] Failed to persist assistant message:', err);
398
+ });
399
+ }
400
+ // 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,
405
+ model,
406
+ latency_ms: latencyMs,
407
+ stream: true,
408
+ }).catch(() => { });
409
+ }
410
+ // 8. Inference log (fire-and-forget)
411
+ if (inferenceLog) {
412
+ logInference(pool, pgSettings, inferenceLog, {
413
+ entityId,
414
+ actorId: userId,
415
+ model,
416
+ provider: 'ollama',
417
+ service: 'llm',
418
+ operation: 'chat',
419
+ inputTokens,
420
+ outputTokens,
421
+ totalTokens,
422
+ latencyMs,
423
+ status: 'ok',
424
+ }).catch(() => { });
425
+ }
426
+ }
427
+ catch (streamErr) {
428
+ log.error('[llm-api] Streaming error:', streamErr);
429
+ const errorEvent = { error: { message: streamErr.message, type: 'stream_error' } };
430
+ res.write(`data: ${JSON.stringify(errorEvent)}\n\n`);
431
+ res.write('data: [DONE]\n\n');
432
+ res.end();
433
+ }
434
+ }
435
+ else {
436
+ // ── Non-streaming (batch) ──────────────────────────────────────────
437
+ const result = await callWithUsage(ollama.client, {
438
+ model,
439
+ messages: llmMessages,
440
+ stream: false,
441
+ temperature: body.temperature,
442
+ });
443
+ const latencyMs = Date.now() - startTime;
444
+ const inputTokens = result.usage.input;
445
+ const outputTokens = result.usage.output;
446
+ const totalTokens = result.usage.totalTokens;
447
+ // Persist assistant message with model
448
+ await withRlsClient(pool, pgSettings, async (client) => {
449
+ await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
450
+ (thread_id, owner_id, entity_id, author_role, parts, model)
451
+ VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
452
+ threadId,
453
+ userId,
454
+ 'assistant',
455
+ JSON.stringify([{ type: 'text', text: result.content }]),
456
+ model,
457
+ ]);
458
+ });
459
+ // Record billing usage
460
+ if (billing && totalTokens > 0) {
461
+ recordUsage(pool, pgSettings, billing, entityId, meterSlug, totalTokens, {
462
+ input_tokens: inputTokens,
463
+ output_tokens: outputTokens,
464
+ model,
465
+ latency_ms: latencyMs,
466
+ stream: false,
467
+ }).catch(() => { });
468
+ }
469
+ // Inference log
470
+ if (inferenceLog) {
471
+ logInference(pool, pgSettings, inferenceLog, {
472
+ entityId,
473
+ actorId: userId,
474
+ model,
475
+ provider: 'ollama',
476
+ service: 'llm',
477
+ operation: 'chat',
478
+ inputTokens,
479
+ outputTokens,
480
+ totalTokens,
481
+ latencyMs,
482
+ status: 'ok',
483
+ }).catch(() => { });
484
+ }
485
+ res.json({
486
+ id: `msg_${Date.now()}`,
487
+ choices: [{
488
+ index: 0,
489
+ message: { role: 'assistant', content: result.content },
490
+ finish_reason: 'stop',
491
+ }],
492
+ model,
493
+ usage: {
494
+ prompt_tokens: inputTokens,
495
+ completion_tokens: outputTokens,
496
+ total_tokens: totalTokens,
497
+ },
498
+ });
499
+ }
500
+ }
501
+ // ─── Router Factory ─────────────────────────────────────────────────────────
502
+ export function createLlmApiRouter() {
503
+ const router = Router();
504
+ router.use(express.json());
505
+ // ── Entity-scoped routes ─────────────────────────────────────────────────
506
+ router.post('/v1/orgs/:entity_id/threads', async (req, res) => {
507
+ try {
508
+ await handleCreateThread(req, res, req.params.entity_id);
509
+ }
510
+ catch (err) {
511
+ log.error('[llm-api] Error creating thread:', err);
512
+ if (!res.headersSent) {
513
+ res.status(500).json({ error: 'Internal server error' });
514
+ }
515
+ }
516
+ });
517
+ router.post('/v1/orgs/:entity_id/threads/:thread_id/messages', async (req, res) => {
518
+ try {
519
+ await handleSendMessage(req, res, req.params.entity_id);
520
+ }
521
+ catch (err) {
522
+ log.error('[llm-api] Error in messages endpoint:', err);
523
+ if (!res.headersSent) {
524
+ res.status(500).json({ error: 'Internal server error' });
525
+ }
526
+ }
527
+ });
528
+ // ── Global routes (no entity_id — bills to actor_id from JWT) ────────────
529
+ router.post('/v1/threads', async (req, res) => {
530
+ try {
531
+ const userId = req.token?.user_id;
532
+ if (!userId) {
533
+ res.status(401).json({ error: 'Authentication required' });
534
+ return;
535
+ }
536
+ await handleCreateThread(req, res, userId);
537
+ }
538
+ catch (err) {
539
+ log.error('[llm-api] Error creating thread:', err);
540
+ if (!res.headersSent) {
541
+ res.status(500).json({ error: 'Internal server error' });
542
+ }
543
+ }
544
+ });
545
+ router.post('/v1/threads/:thread_id/messages', async (req, res) => {
546
+ try {
547
+ const userId = req.token?.user_id;
548
+ if (!userId) {
549
+ res.status(401).json({ error: 'Authentication required' });
550
+ return;
551
+ }
552
+ await handleSendMessage(req, res, userId);
553
+ }
554
+ catch (err) {
555
+ log.error('[llm-api] Error in messages endpoint:', err);
556
+ if (!res.headersSent) {
557
+ res.status(500).json({ error: 'Internal server error' });
558
+ }
559
+ }
560
+ });
561
+ return router;
562
+ }
package/esm/server.js CHANGED
@@ -27,6 +27,7 @@ import { createRequestLogger } from './middleware/observability/request-logger';
27
27
  import { createCaptchaMiddleware } from './middleware/captcha';
28
28
  import { parseCookieValue, SESSION_COOKIE_NAME } from './middleware/cookie';
29
29
  import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
30
+ import { createLlmApiRouter } from './middleware/llm-api';
30
31
  import { startDebugSampler } from './diagnostics/debug-sampler';
31
32
  const log = new Logger('server');
32
33
  /**
@@ -167,6 +168,9 @@ class Server {
167
168
  };
168
169
  app.use(csrfSetToken); // Set CSRF token cookie on all requests
169
170
  app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations
171
+ // LLM Agent REST API — mounted before graphile so SSE streaming
172
+ // routes are handled without going through PostGraphile
173
+ app.use(createLlmApiRouter());
170
174
  app.use(graphile(effectiveOpts));
171
175
  app.use(flush);
172
176
  // Error handling - MUST be LAST
@@ -254,14 +254,21 @@ const buildPreset = (pool, schemas, anonRole, roleName, databaseSettings) => {
254
254
  if (req.token.access_level === 'read_only') {
255
255
  pgSettings['default_transaction_read_only'] = 'on';
256
256
  }
257
+ if (req.requestId) {
258
+ pgSettings['request.id'] = req.requestId;
259
+ }
257
260
  return { pgSettings };
258
261
  }
259
262
  }
263
+ const anonSettings = {
264
+ role: anonRole,
265
+ ...context,
266
+ };
267
+ if (req?.requestId) {
268
+ anonSettings['request.id'] = req.requestId;
269
+ }
260
270
  return {
261
- pgSettings: {
262
- role: anonRole,
263
- ...context,
264
- },
271
+ pgSettings: anonSettings,
265
272
  };
266
273
  },
267
274
  },
@@ -0,0 +1,24 @@
1
+ /**
2
+ * LLM API Router
3
+ *
4
+ * Express router providing REST streaming endpoints for AI agent conversations.
5
+ * Uses the agent tables (agent_thread, agent_message) discovered from the
6
+ * agent_chat_module config table at runtime.
7
+ *
8
+ * Hybrid architecture:
9
+ * - GraphQL handles CRUD (threads, messages, tasks) via PostGraphile
10
+ * - REST handles SSE streaming for chat completions (what GraphQL can't do)
11
+ *
12
+ * Routes (entity-scoped):
13
+ * POST /v1/orgs/:entity_id/threads → create thread
14
+ * POST /v1/orgs/:entity_id/threads/:thread_id/messages → send message + stream response
15
+ *
16
+ * Routes (global — bills to actor_id from JWT):
17
+ * POST /v1/threads → create thread (entity_id = user_id)
18
+ * POST /v1/threads/:thread_id/messages → send message + stream response
19
+ *
20
+ * Auth: JWT from the auth middleware (req.token) → pg SET LOCAL context for RLS
21
+ * Metering: check_billing_quota → LLM call → record_usage with real token counts
22
+ */
23
+ import { Router } from 'express';
24
+ export declare function createLlmApiRouter(): Router;
@@ -0,0 +1,601 @@
1
+ "use strict";
2
+ /**
3
+ * LLM API Router
4
+ *
5
+ * Express router providing REST streaming endpoints for AI agent conversations.
6
+ * Uses the agent tables (agent_thread, agent_message) discovered from the
7
+ * agent_chat_module config table at runtime.
8
+ *
9
+ * Hybrid architecture:
10
+ * - GraphQL handles CRUD (threads, messages, tasks) via PostGraphile
11
+ * - REST handles SSE streaming for chat completions (what GraphQL can't do)
12
+ *
13
+ * Routes (entity-scoped):
14
+ * POST /v1/orgs/:entity_id/threads → create thread
15
+ * POST /v1/orgs/:entity_id/threads/:thread_id/messages → send message + stream response
16
+ *
17
+ * Routes (global — bills to actor_id from JWT):
18
+ * POST /v1/threads → create thread (entity_id = user_id)
19
+ * POST /v1/threads/:thread_id/messages → send message + stream response
20
+ *
21
+ * Auth: JWT from the auth middleware (req.token) → pg SET LOCAL context for RLS
22
+ * Metering: check_billing_quota → LLM call → record_usage with real token counts
23
+ */
24
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
25
+ if (k2 === undefined) k2 = k;
26
+ var desc = Object.getOwnPropertyDescriptor(m, k);
27
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
28
+ desc = { enumerable: true, get: function() { return m[k]; } };
29
+ }
30
+ Object.defineProperty(o, k2, desc);
31
+ }) : (function(o, m, k, k2) {
32
+ if (k2 === undefined) k2 = k;
33
+ o[k2] = m[k];
34
+ }));
35
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
36
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
37
+ }) : function(o, v) {
38
+ o["default"] = v;
39
+ });
40
+ var __importStar = (this && this.__importStar) || (function () {
41
+ var ownKeys = function(o) {
42
+ ownKeys = Object.getOwnPropertyNames || function (o) {
43
+ var ar = [];
44
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
45
+ return ar;
46
+ };
47
+ return ownKeys(o);
48
+ };
49
+ return function (mod) {
50
+ if (mod && mod.__esModule) return mod;
51
+ var result = {};
52
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
53
+ __setModuleDefault(result, mod);
54
+ return result;
55
+ };
56
+ })();
57
+ var __importDefault = (this && this.__importDefault) || function (mod) {
58
+ return (mod && mod.__esModule) ? mod : { "default": mod };
59
+ };
60
+ Object.defineProperty(exports, "__esModule", { value: true });
61
+ exports.createLlmApiRouter = createLlmApiRouter;
62
+ const express_1 = __importStar(require("express"));
63
+ const logger_1 = require("@pgpmjs/logger");
64
+ const pg_cache_1 = require("pg-cache");
65
+ const ollama_1 = __importDefault(require("@agentic-kit/ollama"));
66
+ const graphile_cache_1 = require("graphile-cache");
67
+ const graphile_llm_1 = require("graphile-llm");
68
+ 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
+ // ─── 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() {
133
+ const { chat } = (0, graphile_llm_1.getLlmEnvOptions)();
134
+ if (chat.provider === 'ollama') {
135
+ return {
136
+ client: new ollama_1.default(chat.baseUrl),
137
+ model: chat.model,
138
+ };
139
+ }
140
+ return null;
141
+ }
142
+ // ─── Billing Helpers ────────────────────────────────────────────────────────
143
+ async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
144
+ try {
145
+ return await withRlsClient(pool, pgSettings, async (client) => {
146
+ const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
147
+ const result = await client.query(sql, [meterSlug, entityId, 1]);
148
+ return result.rows[0]?.allowed !== false;
149
+ });
150
+ }
151
+ catch (e) {
152
+ const message = e instanceof Error ? e.message : String(e);
153
+ log.warn(`[llm-api] check_billing_quota failed (allowing): ${message}`);
154
+ return true;
155
+ }
156
+ }
157
+ async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amount, metadata) {
158
+ try {
159
+ await withRlsClient(pool, pgSettings, async (client) => {
160
+ const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
161
+ await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
162
+ });
163
+ }
164
+ catch (e) {
165
+ const message = e instanceof Error ? e.message : String(e);
166
+ log.warn(`[llm-api] record_usage failed (non-fatal): ${message}`);
167
+ }
168
+ }
169
+ async function resolveBilling(pool, pgSettings, databaseId) {
170
+ try {
171
+ let billing = null;
172
+ await withRlsClient(pool, pgSettings, async (client) => {
173
+ const entry = await (0, graphile_llm_1.getLlmBillingConfig)(client, databaseId);
174
+ billing = entry.billing;
175
+ });
176
+ return billing;
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ const INFERENCE_LOG_DISCOVERY_SQL = `
183
+ SELECT s.schema_name, ilm.inference_log_table_name
184
+ FROM metaschema_modules_public.inference_log_module ilm
185
+ JOIN metaschema_public.schema s ON s.id = ilm.schema_id
186
+ LIMIT 1
187
+ `;
188
+ const inferenceLogCache = new graphile_cache_1.ModuleConfigCache({
189
+ name: 'inference-log',
190
+ ttlMs: 60_000,
191
+ });
192
+ async function getInferenceLogInfo(pool, dbname) {
193
+ const cached = inferenceLogCache.get(dbname);
194
+ if (cached !== undefined)
195
+ return cached;
196
+ let info = null;
197
+ try {
198
+ const { rows } = await pool.query(INFERENCE_LOG_DISCOVERY_SQL);
199
+ if (rows.length > 0) {
200
+ info = {
201
+ schemaName: rows[0].schema_name,
202
+ tableName: rows[0].inference_log_table_name,
203
+ };
204
+ }
205
+ }
206
+ catch {
207
+ // Module not provisioned
208
+ }
209
+ inferenceLogCache.set(dbname, info);
210
+ return info;
211
+ }
212
+ async function logInference(pool, pgSettings, logInfo, data) {
213
+ try {
214
+ await withRlsClient(pool, pgSettings, async (client) => {
215
+ await client.query(`INSERT INTO "${logInfo.schemaName}"."${logInfo.tableName}"
216
+ (entity_id, actor_id, model, provider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status)
217
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
218
+ data.entityId,
219
+ data.actorId,
220
+ data.model,
221
+ data.provider,
222
+ data.service,
223
+ data.operation,
224
+ data.inputTokens,
225
+ data.outputTokens,
226
+ data.totalTokens,
227
+ data.latencyMs,
228
+ data.status,
229
+ ]);
230
+ });
231
+ }
232
+ catch (e) {
233
+ const message = e instanceof Error ? e.message : String(e);
234
+ log.warn(`[llm-api] inference log INSERT failed (non-fatal): ${message}`);
235
+ }
236
+ }
237
+ // ─── Route Handlers ─────────────────────────────────────────────────────────
238
+ async function handleCreateThread(req, res, entityId) {
239
+ if (!req.token?.user_id) {
240
+ res.status(401).json({ error: 'Authentication required' });
241
+ return;
242
+ }
243
+ const dbname = req.api?.dbname;
244
+ if (!dbname) {
245
+ res.status(400).json({ error: 'Database not resolved' });
246
+ return;
247
+ }
248
+ const pool = (0, pg_cache_1.getPgPool)({ database: dbname });
249
+ const discovery = await (0, graphile_llm_1.getAgentDiscovery)(pool, dbname);
250
+ if (!discovery?.thread) {
251
+ res.status(404).json({ error: 'Agent module not provisioned for this database' });
252
+ return;
253
+ }
254
+ const body = req.body || {};
255
+ const { thread } = discovery;
256
+ const pgSettings = getPgSettings(req);
257
+ const result = await withRlsClient(pool, pgSettings, async (client) => {
258
+ const { rows } = await client.query(`INSERT INTO "${thread.schemaName}"."${thread.tableName}"
259
+ (entity_id, owner_id, mode, model, system_prompt, title)
260
+ VALUES ($1, $2, $3, $4, $5, $6)
261
+ RETURNING id, mode, model, system_prompt, status, created_at`, [
262
+ entityId,
263
+ req.token.user_id,
264
+ body.mode ?? 'ask',
265
+ body.model ?? null,
266
+ body.system_prompt ?? null,
267
+ body.title ?? null,
268
+ ]);
269
+ return rows[0];
270
+ });
271
+ res.status(201).json({
272
+ id: result.id,
273
+ mode: result.mode,
274
+ model: result.model,
275
+ system_prompt: result.system_prompt,
276
+ status: result.status,
277
+ created_at: result.created_at,
278
+ });
279
+ }
280
+ async function handleSendMessage(req, res, entityId) {
281
+ if (!req.token?.user_id) {
282
+ res.status(401).json({ error: 'Authentication required' });
283
+ return;
284
+ }
285
+ const dbname = req.api?.dbname;
286
+ if (!dbname) {
287
+ res.status(400).json({ error: 'Database not resolved' });
288
+ return;
289
+ }
290
+ const pool = (0, pg_cache_1.getPgPool)({ database: dbname });
291
+ const discovery = await (0, graphile_llm_1.getAgentDiscovery)(pool, dbname);
292
+ if (!discovery?.thread || !discovery?.message) {
293
+ res.status(404).json({ error: 'Agent module not provisioned for this database' });
294
+ return;
295
+ }
296
+ const body = req.body || {};
297
+ if (!body.messages?.length) {
298
+ res.status(400).json({ error: 'messages[] is required and must not be empty' });
299
+ return;
300
+ }
301
+ const { thread, message: msgTable } = discovery;
302
+ const pgSettings = getPgSettings(req);
303
+ const threadId = req.params.thread_id;
304
+ const userId = req.token.user_id;
305
+ const databaseId = req.databaseId;
306
+ // 1. Verify thread exists and user owns it (RLS enforced)
307
+ const threadRow = await withRlsClient(pool, pgSettings, async (client) => {
308
+ const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
309
+ FROM "${thread.schemaName}"."${thread.tableName}"
310
+ WHERE id = $1`, [threadId]);
311
+ return rows[0];
312
+ });
313
+ if (!threadRow) {
314
+ res.status(404).json({ error: 'Thread not found' });
315
+ return;
316
+ }
317
+ // 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();
323
+ if (!ollama) {
324
+ res.status(503).json({ error: 'No LLM provider configured' });
325
+ return;
326
+ }
327
+ const model = body.model ?? threadRow.model ?? ollama.model;
328
+ const meterSlug = model;
329
+ if (billing) {
330
+ const allowed = await checkQuota(pool, pgSettings, billing, entityId, meterSlug);
331
+ if (!allowed) {
332
+ res.status(429).json({
333
+ error: 'Token quota exceeded',
334
+ meter: meterSlug,
335
+ entity_id: entityId,
336
+ });
337
+ return;
338
+ }
339
+ }
340
+ // 3. Persist user message(s)
341
+ await withRlsClient(pool, pgSettings, async (client) => {
342
+ for (const msg of body.messages) {
343
+ if (msg.role === 'user') {
344
+ await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
345
+ (thread_id, owner_id, entity_id, author_role, parts)
346
+ VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4)`, [
347
+ threadId,
348
+ userId,
349
+ 'user',
350
+ JSON.stringify([{ type: 'text', text: msg.content }]),
351
+ ]);
352
+ }
353
+ }
354
+ });
355
+ // 4. Load full thread history for context
356
+ const history = await withRlsClient(pool, pgSettings, async (client) => {
357
+ const { rows } = await client.query(`SELECT author_role, parts, created_at
358
+ FROM "${msgTable.schemaName}"."${msgTable.tableName}"
359
+ WHERE thread_id = $1
360
+ ORDER BY created_at ASC`, [threadId]);
361
+ return rows;
362
+ });
363
+ const llmMessages = [];
364
+ const systemPrompt = threadRow.system_prompt;
365
+ if (systemPrompt) {
366
+ llmMessages.push({ role: 'system', content: systemPrompt });
367
+ }
368
+ for (const row of history) {
369
+ const parts = Array.isArray(row.parts) ? row.parts : [];
370
+ const textContent = parts
371
+ .filter((p) => p.type === 'text')
372
+ .map((p) => p.text)
373
+ .join('');
374
+ if (textContent) {
375
+ llmMessages.push({
376
+ role: row.author_role === 'user' ? 'user' : 'assistant',
377
+ content: textContent,
378
+ });
379
+ }
380
+ }
381
+ // 5. Call LLM with token usage tracking
382
+ const shouldStream = body.stream !== false;
383
+ const startTime = Date.now();
384
+ if (shouldStream) {
385
+ // ── SSE Streaming ──────────────────────────────────────────────────
386
+ res.writeHead(200, {
387
+ 'Content-Type': 'text/event-stream',
388
+ 'Cache-Control': 'no-cache',
389
+ 'Connection': 'keep-alive',
390
+ 'X-Accel-Buffering': 'no',
391
+ });
392
+ const messageId = `msg_${Date.now()}`;
393
+ try {
394
+ let streamedContent = '';
395
+ const result = await callWithUsage(ollama.client, {
396
+ model,
397
+ messages: llmMessages,
398
+ stream: true,
399
+ 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
+ });
413
+ // Streaming generate() returns void; use accumulated chunks
414
+ const content = streamedContent;
415
+ const promptText = llmMessages.map(m => m.content).join(' ');
416
+ const latencyMs = Date.now() - startTime;
417
+ const inputTokens = placeholderAmountTokens(promptText);
418
+ const outputTokens = placeholderAmountTokens(content);
419
+ const totalTokens = inputTokens + outputTokens;
420
+ // Send [DONE] marker
421
+ res.write('data: [DONE]\n\n');
422
+ res.end();
423
+ // 6. Persist assistant message with model (fire-and-forget)
424
+ if (content) {
425
+ withRlsClient(pool, pgSettings, async (client) => {
426
+ await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
427
+ (thread_id, owner_id, entity_id, author_role, parts, model)
428
+ VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
429
+ threadId,
430
+ userId,
431
+ 'assistant',
432
+ JSON.stringify([{ type: 'text', text: content }]),
433
+ model,
434
+ ]);
435
+ }).catch((err) => {
436
+ log.error('[llm-api] Failed to persist assistant message:', err);
437
+ });
438
+ }
439
+ // 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,
444
+ model,
445
+ latency_ms: latencyMs,
446
+ stream: true,
447
+ }).catch(() => { });
448
+ }
449
+ // 8. Inference log (fire-and-forget)
450
+ if (inferenceLog) {
451
+ logInference(pool, pgSettings, inferenceLog, {
452
+ entityId,
453
+ actorId: userId,
454
+ model,
455
+ provider: 'ollama',
456
+ service: 'llm',
457
+ operation: 'chat',
458
+ inputTokens,
459
+ outputTokens,
460
+ totalTokens,
461
+ latencyMs,
462
+ status: 'ok',
463
+ }).catch(() => { });
464
+ }
465
+ }
466
+ catch (streamErr) {
467
+ log.error('[llm-api] Streaming error:', streamErr);
468
+ const errorEvent = { error: { message: streamErr.message, type: 'stream_error' } };
469
+ res.write(`data: ${JSON.stringify(errorEvent)}\n\n`);
470
+ res.write('data: [DONE]\n\n');
471
+ res.end();
472
+ }
473
+ }
474
+ else {
475
+ // ── Non-streaming (batch) ──────────────────────────────────────────
476
+ const result = await callWithUsage(ollama.client, {
477
+ model,
478
+ messages: llmMessages,
479
+ stream: false,
480
+ temperature: body.temperature,
481
+ });
482
+ const latencyMs = Date.now() - startTime;
483
+ const inputTokens = result.usage.input;
484
+ const outputTokens = result.usage.output;
485
+ const totalTokens = result.usage.totalTokens;
486
+ // Persist assistant message with model
487
+ await withRlsClient(pool, pgSettings, async (client) => {
488
+ await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
489
+ (thread_id, owner_id, entity_id, author_role, parts, model)
490
+ VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
491
+ threadId,
492
+ userId,
493
+ 'assistant',
494
+ JSON.stringify([{ type: 'text', text: result.content }]),
495
+ model,
496
+ ]);
497
+ });
498
+ // Record billing usage
499
+ if (billing && totalTokens > 0) {
500
+ recordUsage(pool, pgSettings, billing, entityId, meterSlug, totalTokens, {
501
+ input_tokens: inputTokens,
502
+ output_tokens: outputTokens,
503
+ model,
504
+ latency_ms: latencyMs,
505
+ stream: false,
506
+ }).catch(() => { });
507
+ }
508
+ // Inference log
509
+ if (inferenceLog) {
510
+ logInference(pool, pgSettings, inferenceLog, {
511
+ entityId,
512
+ actorId: userId,
513
+ model,
514
+ provider: 'ollama',
515
+ service: 'llm',
516
+ operation: 'chat',
517
+ inputTokens,
518
+ outputTokens,
519
+ totalTokens,
520
+ latencyMs,
521
+ status: 'ok',
522
+ }).catch(() => { });
523
+ }
524
+ res.json({
525
+ id: `msg_${Date.now()}`,
526
+ choices: [{
527
+ index: 0,
528
+ message: { role: 'assistant', content: result.content },
529
+ finish_reason: 'stop',
530
+ }],
531
+ model,
532
+ usage: {
533
+ prompt_tokens: inputTokens,
534
+ completion_tokens: outputTokens,
535
+ total_tokens: totalTokens,
536
+ },
537
+ });
538
+ }
539
+ }
540
+ // ─── Router Factory ─────────────────────────────────────────────────────────
541
+ function createLlmApiRouter() {
542
+ const router = (0, express_1.Router)();
543
+ router.use(express_1.default.json());
544
+ // ── Entity-scoped routes ─────────────────────────────────────────────────
545
+ router.post('/v1/orgs/:entity_id/threads', async (req, res) => {
546
+ try {
547
+ await handleCreateThread(req, res, req.params.entity_id);
548
+ }
549
+ catch (err) {
550
+ log.error('[llm-api] Error creating thread:', err);
551
+ if (!res.headersSent) {
552
+ res.status(500).json({ error: 'Internal server error' });
553
+ }
554
+ }
555
+ });
556
+ router.post('/v1/orgs/:entity_id/threads/:thread_id/messages', async (req, res) => {
557
+ try {
558
+ await handleSendMessage(req, res, req.params.entity_id);
559
+ }
560
+ catch (err) {
561
+ log.error('[llm-api] Error in messages endpoint:', err);
562
+ if (!res.headersSent) {
563
+ res.status(500).json({ error: 'Internal server error' });
564
+ }
565
+ }
566
+ });
567
+ // ── Global routes (no entity_id — bills to actor_id from JWT) ────────────
568
+ router.post('/v1/threads', async (req, res) => {
569
+ try {
570
+ const userId = req.token?.user_id;
571
+ if (!userId) {
572
+ res.status(401).json({ error: 'Authentication required' });
573
+ return;
574
+ }
575
+ await handleCreateThread(req, res, userId);
576
+ }
577
+ catch (err) {
578
+ log.error('[llm-api] Error creating thread:', err);
579
+ if (!res.headersSent) {
580
+ res.status(500).json({ error: 'Internal server error' });
581
+ }
582
+ }
583
+ });
584
+ router.post('/v1/threads/:thread_id/messages', async (req, res) => {
585
+ try {
586
+ const userId = req.token?.user_id;
587
+ if (!userId) {
588
+ res.status(401).json({ error: 'Authentication required' });
589
+ return;
590
+ }
591
+ await handleSendMessage(req, res, userId);
592
+ }
593
+ catch (err) {
594
+ log.error('[llm-api] Error in messages endpoint:', err);
595
+ if (!res.headersSent) {
596
+ res.status(500).json({ error: 'Internal server error' });
597
+ }
598
+ }
599
+ });
600
+ return router;
601
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@constructive-io/graphql-server",
3
- "version": "4.31.5",
3
+ "version": "4.32.0",
4
4
  "author": "Constructive <developers@constructive.io>",
5
5
  "description": "Constructive GraphQL Server",
6
6
  "main": "index.js",
@@ -41,10 +41,11 @@
41
41
  "backend"
42
42
  ],
43
43
  "dependencies": {
44
+ "@agentic-kit/ollama": "^1.2.1",
44
45
  "@constructive-io/csrf": "^0.14.0",
45
- "@constructive-io/graphql-env": "^3.11.0",
46
- "@constructive-io/graphql-types": "^3.10.0",
47
- "@constructive-io/s3-utils": "^2.17.0",
46
+ "@constructive-io/graphql-env": "^3.11.1",
47
+ "@constructive-io/graphql-types": "^3.10.1",
48
+ "@constructive-io/s3-utils": "^2.17.1",
48
49
  "@constructive-io/upload-names": "^2.16.0",
49
50
  "@constructive-io/url-domains": "^2.16.0",
50
51
  "@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
@@ -57,40 +58,41 @@
57
58
  "deepmerge": "^4.3.1",
58
59
  "express": "^5.2.1",
59
60
  "gql-ast": "^3.10.0",
60
- "grafast": "1.0.0",
61
+ "grafast": "1.0.2",
61
62
  "grafserv": "1.0.0",
62
- "graphile-build": "5.0.0",
63
- "graphile-build-pg": "5.0.0",
64
- "graphile-cache": "^3.11.1",
65
- "graphile-config": "1.0.0",
66
- "graphile-settings": "^5.2.3",
67
- "graphile-utils": "5.0.0",
63
+ "graphile-build": "5.0.2",
64
+ "graphile-build-pg": "5.0.2",
65
+ "graphile-cache": "^3.11.2",
66
+ "graphile-config": "1.0.1",
67
+ "graphile-llm": "^0.8.0",
68
+ "graphile-settings": "^5.2.4",
69
+ "graphile-utils": "5.0.1",
68
70
  "graphql": "16.13.0",
69
71
  "graphql-upload": "^13.0.0",
70
72
  "lru-cache": "^11.2.7",
71
73
  "multer": "^2.1.1",
72
- "pg": "^8.20.0",
73
- "pg-cache": "^3.10.0",
74
+ "pg": "^8.21.0",
75
+ "pg-cache": "^3.10.1",
74
76
  "pg-env": "^1.14.0",
75
- "pg-query-context": "^2.15.0",
76
- "pg-sql2": "5.0.0",
77
- "postgraphile": "5.0.0",
77
+ "pg-query-context": "^2.15.1",
78
+ "pg-sql2": "5.0.1",
79
+ "postgraphile": "5.0.3",
78
80
  "request-ip": "^3.3.0"
79
81
  },
80
82
  "devDependencies": {
81
- "@aws-sdk/client-s3": "^3.1009.0",
83
+ "@aws-sdk/client-s3": "^3.1052.0",
82
84
  "@types/cookie-parser": "^1.4.10",
83
85
  "@types/cors": "^2.8.17",
84
86
  "@types/express": "^5.0.6",
85
87
  "@types/graphql-upload": "^8.0.12",
86
88
  "@types/multer": "^2.1.0",
87
- "@types/pg": "^8.18.0",
89
+ "@types/pg": "^8.20.0",
88
90
  "@types/request-ip": "^0.0.41",
89
91
  "cookie-parser": "^1.4.7",
90
- "graphile-test": "4.15.2",
92
+ "graphile-test": "4.15.3",
91
93
  "makage": "^0.3.0",
92
94
  "nodemon": "^3.1.14",
93
95
  "ts-node": "^10.9.2"
94
96
  },
95
- "gitHead": "7b7c25cb21fd0e97c2ab3c94b994b0ca89bd3247"
97
+ "gitHead": "030e1144acbd4e288ee74eff2ac0021ca0382ef7"
96
98
  }
package/server.js CHANGED
@@ -33,6 +33,7 @@ const request_logger_1 = require("./middleware/observability/request-logger");
33
33
  const captcha_1 = require("./middleware/captcha");
34
34
  const cookie_1 = require("./middleware/cookie");
35
35
  const upload_1 = require("./middleware/upload");
36
+ const llm_api_1 = require("./middleware/llm-api");
36
37
  const debug_sampler_1 = require("./diagnostics/debug-sampler");
37
38
  const log = new logger_1.Logger('server');
38
39
  /**
@@ -174,6 +175,9 @@ class Server {
174
175
  };
175
176
  app.use(csrfSetToken); // Set CSRF token cookie on all requests
176
177
  app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations
178
+ // LLM Agent REST API — mounted before graphile so SSE streaming
179
+ // routes are handled without going through PostGraphile
180
+ app.use((0, llm_api_1.createLlmApiRouter)());
177
181
  app.use((0, graphile_1.graphile)(effectiveOpts));
178
182
  app.use(flush_1.flush);
179
183
  // Error handling - MUST be LAST