@constructive-io/graphql-server 4.33.0 → 4.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/esm/middleware/api.js +45 -402
- package/esm/middleware/llm-api.js +39 -75
- package/esm/server.js +3 -0
- package/middleware/api.js +45 -402
- package/middleware/llm-api.d.ts +3 -0
- package/middleware/llm-api.js +39 -75
- package/package.json +15 -14
- package/server.js +3 -0
- package/types.d.ts +1 -124
|
@@ -19,48 +19,17 @@
|
|
|
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
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
32
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
31
|
-
function getPgSettings(req) {
|
|
32
|
-
const settings = {};
|
|
33
|
-
if (req.token?.user_id) {
|
|
34
|
-
settings['jwt.claims.user_id'] = req.token.user_id;
|
|
35
|
-
settings['role'] = 'authenticated';
|
|
36
|
-
}
|
|
37
|
-
if (req.databaseId) {
|
|
38
|
-
settings['jwt.claims.database_id'] = req.databaseId;
|
|
39
|
-
}
|
|
40
|
-
if (req.requestId) {
|
|
41
|
-
settings['request.id'] = req.requestId;
|
|
42
|
-
}
|
|
43
|
-
return settings;
|
|
44
|
-
}
|
|
45
|
-
async function withRlsClient(pool, pgSettings, fn) {
|
|
46
|
-
const client = await pool.connect();
|
|
47
|
-
try {
|
|
48
|
-
await client.query('BEGIN');
|
|
49
|
-
for (const [key, value] of Object.entries(pgSettings)) {
|
|
50
|
-
await client.query('SELECT set_config($1, $2, true)', [key, value]);
|
|
51
|
-
}
|
|
52
|
-
const result = await fn(client);
|
|
53
|
-
await client.query('COMMIT');
|
|
54
|
-
return result;
|
|
55
|
-
}
|
|
56
|
-
catch (err) {
|
|
57
|
-
await client.query('ROLLBACK').catch(() => { });
|
|
58
|
-
throw err;
|
|
59
|
-
}
|
|
60
|
-
finally {
|
|
61
|
-
client.release();
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
33
|
function resolveOllamaAdapter() {
|
|
65
34
|
const { chat } = getLlmEnvOptions();
|
|
66
35
|
if (chat.provider === 'ollama') {
|
|
@@ -73,9 +42,9 @@ function resolveOllamaAdapter() {
|
|
|
73
42
|
return null;
|
|
74
43
|
}
|
|
75
44
|
// ─── Billing Helpers ────────────────────────────────────────────────────────
|
|
76
|
-
async function checkQuota(
|
|
45
|
+
async function checkQuota(ctx, billing, entityId, meterSlug) {
|
|
77
46
|
try {
|
|
78
|
-
return await
|
|
47
|
+
return await ctx.withPgClient(async (client) => {
|
|
79
48
|
const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
|
|
80
49
|
const result = await client.query(sql, [meterSlug, entityId, 1]);
|
|
81
50
|
return result.rows[0]?.allowed !== false;
|
|
@@ -87,9 +56,9 @@ async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
|
|
|
87
56
|
return true;
|
|
88
57
|
}
|
|
89
58
|
}
|
|
90
|
-
async function recordUsage(
|
|
59
|
+
async function recordUsage(ctx, billing, entityId, meterSlug, amount, metadata) {
|
|
91
60
|
try {
|
|
92
|
-
await
|
|
61
|
+
await ctx.withPgClient(async (client) => {
|
|
93
62
|
const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
|
|
94
63
|
await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
|
|
95
64
|
});
|
|
@@ -99,11 +68,13 @@ async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amoun
|
|
|
99
68
|
log.warn(`[llm-api] record_usage failed (non-fatal): ${message}`);
|
|
100
69
|
}
|
|
101
70
|
}
|
|
102
|
-
async function resolveBilling(
|
|
71
|
+
async function resolveBilling(ctx) {
|
|
72
|
+
if (!ctx.databaseId)
|
|
73
|
+
return null;
|
|
103
74
|
try {
|
|
104
75
|
let billing = null;
|
|
105
|
-
await
|
|
106
|
-
const entry = await getLlmBillingConfig(client, databaseId);
|
|
76
|
+
await ctx.withPgClient(async (client) => {
|
|
77
|
+
const entry = await getLlmBillingConfig(client, ctx.databaseId);
|
|
107
78
|
billing = entry.billing;
|
|
108
79
|
});
|
|
109
80
|
return billing;
|
|
@@ -142,9 +113,9 @@ async function getInferenceLogInfo(pool, dbname) {
|
|
|
142
113
|
inferenceLogCache.set(dbname, info);
|
|
143
114
|
return info;
|
|
144
115
|
}
|
|
145
|
-
async function logInference(
|
|
116
|
+
async function logInference(ctx, logInfo, data) {
|
|
146
117
|
try {
|
|
147
|
-
await
|
|
118
|
+
await ctx.withPgClient(async (client) => {
|
|
148
119
|
await client.query(`INSERT INTO "${logInfo.schemaName}"."${logInfo.tableName}"
|
|
149
120
|
(entity_id, actor_id, model, provider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status)
|
|
150
121
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
|
|
@@ -169,31 +140,29 @@ async function logInference(pool, pgSettings, logInfo, data) {
|
|
|
169
140
|
}
|
|
170
141
|
// ─── Route Handlers ─────────────────────────────────────────────────────────
|
|
171
142
|
async function handleCreateThread(req, res, entityId) {
|
|
172
|
-
|
|
143
|
+
const ctx = req.constructive;
|
|
144
|
+
if (!ctx?.userId) {
|
|
173
145
|
res.status(401).json({ error: 'Authentication required' });
|
|
174
146
|
return;
|
|
175
147
|
}
|
|
176
|
-
|
|
177
|
-
if (!dbname) {
|
|
148
|
+
if (!ctx.api.dbname) {
|
|
178
149
|
res.status(400).json({ error: 'Database not resolved' });
|
|
179
150
|
return;
|
|
180
151
|
}
|
|
181
|
-
const
|
|
182
|
-
const discovery = await getAgentDiscovery(pool, dbname);
|
|
152
|
+
const discovery = await getAgentDiscovery(ctx.pool, ctx.api.dbname);
|
|
183
153
|
if (!discovery?.thread) {
|
|
184
154
|
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
185
155
|
return;
|
|
186
156
|
}
|
|
187
157
|
const body = req.body || {};
|
|
188
158
|
const { thread } = discovery;
|
|
189
|
-
const
|
|
190
|
-
const result = await withRlsClient(pool, pgSettings, async (client) => {
|
|
159
|
+
const result = await ctx.withPgClient(async (client) => {
|
|
191
160
|
const { rows } = await client.query(`INSERT INTO "${thread.schemaName}"."${thread.tableName}"
|
|
192
161
|
(entity_id, owner_id, mode, model, system_prompt, title)
|
|
193
162
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
194
163
|
RETURNING id, mode, model, system_prompt, status, created_at`, [
|
|
195
164
|
entityId,
|
|
196
|
-
|
|
165
|
+
ctx.userId,
|
|
197
166
|
body.mode ?? 'ask',
|
|
198
167
|
body.model ?? null,
|
|
199
168
|
body.system_prompt ?? null,
|
|
@@ -211,17 +180,16 @@ async function handleCreateThread(req, res, entityId) {
|
|
|
211
180
|
});
|
|
212
181
|
}
|
|
213
182
|
async function handleSendMessage(req, res, entityId) {
|
|
214
|
-
|
|
183
|
+
const ctx = req.constructive;
|
|
184
|
+
if (!ctx?.userId) {
|
|
215
185
|
res.status(401).json({ error: 'Authentication required' });
|
|
216
186
|
return;
|
|
217
187
|
}
|
|
218
|
-
|
|
219
|
-
if (!dbname) {
|
|
188
|
+
if (!ctx.api.dbname) {
|
|
220
189
|
res.status(400).json({ error: 'Database not resolved' });
|
|
221
190
|
return;
|
|
222
191
|
}
|
|
223
|
-
const
|
|
224
|
-
const discovery = await getAgentDiscovery(pool, dbname);
|
|
192
|
+
const discovery = await getAgentDiscovery(ctx.pool, ctx.api.dbname);
|
|
225
193
|
if (!discovery?.thread || !discovery?.message) {
|
|
226
194
|
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
227
195
|
return;
|
|
@@ -232,12 +200,10 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
232
200
|
return;
|
|
233
201
|
}
|
|
234
202
|
const { thread, message: msgTable } = discovery;
|
|
235
|
-
const pgSettings = getPgSettings(req);
|
|
236
203
|
const threadId = req.params.thread_id;
|
|
237
|
-
const userId =
|
|
238
|
-
const databaseId = req.databaseId;
|
|
204
|
+
const userId = ctx.userId;
|
|
239
205
|
// 1. Verify thread exists and user owns it (RLS enforced)
|
|
240
|
-
const threadRow = await
|
|
206
|
+
const threadRow = await ctx.withPgClient(async (client) => {
|
|
241
207
|
const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
|
|
242
208
|
FROM "${thread.schemaName}"."${thread.tableName}"
|
|
243
209
|
WHERE id = $1`, [threadId]);
|
|
@@ -248,10 +214,8 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
248
214
|
return;
|
|
249
215
|
}
|
|
250
216
|
// 2. Resolve billing config + inference log discovery
|
|
251
|
-
const billing =
|
|
252
|
-
|
|
253
|
-
: null;
|
|
254
|
-
const inferenceLog = await getInferenceLogInfo(pool, dbname);
|
|
217
|
+
const billing = await resolveBilling(ctx);
|
|
218
|
+
const inferenceLog = await getInferenceLogInfo(ctx.pool, ctx.api.dbname);
|
|
255
219
|
const ollama = resolveOllamaAdapter();
|
|
256
220
|
if (!ollama) {
|
|
257
221
|
res.status(503).json({ error: 'No LLM provider configured' });
|
|
@@ -260,7 +224,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
260
224
|
const model = body.model ?? threadRow.model ?? ollama.model;
|
|
261
225
|
const meterSlug = model;
|
|
262
226
|
if (billing) {
|
|
263
|
-
const allowed = await checkQuota(
|
|
227
|
+
const allowed = await checkQuota(ctx, billing, entityId, meterSlug);
|
|
264
228
|
if (!allowed) {
|
|
265
229
|
res.status(429).json({
|
|
266
230
|
error: 'Token quota exceeded',
|
|
@@ -271,7 +235,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
271
235
|
}
|
|
272
236
|
}
|
|
273
237
|
// 3. Persist user message(s)
|
|
274
|
-
await
|
|
238
|
+
await ctx.withPgClient(async (client) => {
|
|
275
239
|
for (const msg of body.messages) {
|
|
276
240
|
if (msg.role === 'user') {
|
|
277
241
|
await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
@@ -286,7 +250,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
286
250
|
}
|
|
287
251
|
});
|
|
288
252
|
// 4. Load full thread history for context
|
|
289
|
-
const history = await
|
|
253
|
+
const history = await ctx.withPgClient(async (client) => {
|
|
290
254
|
const { rows } = await client.query(`SELECT author_role, parts, created_at
|
|
291
255
|
FROM "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
292
256
|
WHERE thread_id = $1
|
|
@@ -372,7 +336,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
372
336
|
res.end();
|
|
373
337
|
// 6. Persist assistant message with model (fire-and-forget)
|
|
374
338
|
if (content) {
|
|
375
|
-
|
|
339
|
+
ctx.withPgClient(async (client) => {
|
|
376
340
|
await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
377
341
|
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
378
342
|
VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
|
|
@@ -388,7 +352,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
388
352
|
}
|
|
389
353
|
// 7. Record billing usage (fire-and-forget)
|
|
390
354
|
if (billing && usage.totalTokens > 0) {
|
|
391
|
-
recordUsage(
|
|
355
|
+
recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
|
|
392
356
|
input_tokens: usage.input,
|
|
393
357
|
output_tokens: usage.output,
|
|
394
358
|
cache_read_tokens: usage.cacheRead,
|
|
@@ -400,7 +364,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
400
364
|
}
|
|
401
365
|
// 8. Inference log (fire-and-forget)
|
|
402
366
|
if (inferenceLog) {
|
|
403
|
-
logInference(
|
|
367
|
+
logInference(ctx, inferenceLog, {
|
|
404
368
|
entityId,
|
|
405
369
|
actorId: userId,
|
|
406
370
|
model,
|
|
@@ -456,7 +420,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
456
420
|
totalTokens: result.usage.totalTokens,
|
|
457
421
|
};
|
|
458
422
|
// Persist assistant message with model
|
|
459
|
-
await
|
|
423
|
+
await ctx.withPgClient(async (client) => {
|
|
460
424
|
await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
461
425
|
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
462
426
|
VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
|
|
@@ -469,7 +433,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
469
433
|
});
|
|
470
434
|
// Record billing usage
|
|
471
435
|
if (billing && usage.totalTokens > 0) {
|
|
472
|
-
recordUsage(
|
|
436
|
+
recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
|
|
473
437
|
input_tokens: usage.input,
|
|
474
438
|
output_tokens: usage.output,
|
|
475
439
|
cache_read_tokens: usage.cacheRead,
|
|
@@ -481,7 +445,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
481
445
|
}
|
|
482
446
|
// Inference log
|
|
483
447
|
if (inferenceLog) {
|
|
484
|
-
logInference(
|
|
448
|
+
logInference(ctx, inferenceLog, {
|
|
485
449
|
entityId,
|
|
486
450
|
actorId: userId,
|
|
487
451
|
model,
|
|
@@ -541,7 +505,7 @@ export function createLlmApiRouter() {
|
|
|
541
505
|
// ── Global routes (no entity_id — bills to actor_id from JWT) ────────────
|
|
542
506
|
router.post('/v1/threads', async (req, res) => {
|
|
543
507
|
try {
|
|
544
|
-
const userId = req.
|
|
508
|
+
const userId = req.constructive?.userId;
|
|
545
509
|
if (!userId) {
|
|
546
510
|
res.status(401).json({ error: 'Authentication required' });
|
|
547
511
|
return;
|
|
@@ -557,7 +521,7 @@ export function createLlmApiRouter() {
|
|
|
557
521
|
});
|
|
558
522
|
router.post('/v1/threads/:thread_id/messages', async (req, res) => {
|
|
559
523
|
try {
|
|
560
|
-
const userId = req.
|
|
524
|
+
const userId = req.constructive?.userId;
|
|
561
525
|
if (!userId) {
|
|
562
526
|
res.status(401).json({ error: 'Authentication required' });
|
|
563
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
|