@constructive-io/graphql-server 4.33.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.
- package/esm/middleware/llm-api.js +39 -75
- package/esm/server.js +3 -0
- 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
|
@@ -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
|
package/middleware/llm-api.d.ts
CHANGED
|
@@ -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;
|
package/middleware/llm-api.js
CHANGED
|
@@ -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;
|
|
@@ -58,45 +61,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
58
61
|
exports.createLlmApiRouter = createLlmApiRouter;
|
|
59
62
|
const express_1 = __importStar(require("express"));
|
|
60
63
|
const logger_1 = require("@pgpmjs/logger");
|
|
61
|
-
const pg_cache_1 = require("pg-cache");
|
|
62
64
|
const ollama_1 = require("@agentic-kit/ollama");
|
|
63
65
|
const graphile_cache_1 = require("graphile-cache");
|
|
64
66
|
const graphile_llm_1 = require("graphile-llm");
|
|
65
67
|
const log = new logger_1.Logger('llm-api');
|
|
66
68
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
67
|
-
function getPgSettings(req) {
|
|
68
|
-
const settings = {};
|
|
69
|
-
if (req.token?.user_id) {
|
|
70
|
-
settings['jwt.claims.user_id'] = req.token.user_id;
|
|
71
|
-
settings['role'] = 'authenticated';
|
|
72
|
-
}
|
|
73
|
-
if (req.databaseId) {
|
|
74
|
-
settings['jwt.claims.database_id'] = req.databaseId;
|
|
75
|
-
}
|
|
76
|
-
if (req.requestId) {
|
|
77
|
-
settings['request.id'] = req.requestId;
|
|
78
|
-
}
|
|
79
|
-
return settings;
|
|
80
|
-
}
|
|
81
|
-
async function withRlsClient(pool, pgSettings, fn) {
|
|
82
|
-
const client = await pool.connect();
|
|
83
|
-
try {
|
|
84
|
-
await client.query('BEGIN');
|
|
85
|
-
for (const [key, value] of Object.entries(pgSettings)) {
|
|
86
|
-
await client.query('SELECT set_config($1, $2, true)', [key, value]);
|
|
87
|
-
}
|
|
88
|
-
const result = await fn(client);
|
|
89
|
-
await client.query('COMMIT');
|
|
90
|
-
return result;
|
|
91
|
-
}
|
|
92
|
-
catch (err) {
|
|
93
|
-
await client.query('ROLLBACK').catch(() => { });
|
|
94
|
-
throw err;
|
|
95
|
-
}
|
|
96
|
-
finally {
|
|
97
|
-
client.release();
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
69
|
function resolveOllamaAdapter() {
|
|
101
70
|
const { chat } = (0, graphile_llm_1.getLlmEnvOptions)();
|
|
102
71
|
if (chat.provider === 'ollama') {
|
|
@@ -109,9 +78,9 @@ function resolveOllamaAdapter() {
|
|
|
109
78
|
return null;
|
|
110
79
|
}
|
|
111
80
|
// ─── Billing Helpers ────────────────────────────────────────────────────────
|
|
112
|
-
async function checkQuota(
|
|
81
|
+
async function checkQuota(ctx, billing, entityId, meterSlug) {
|
|
113
82
|
try {
|
|
114
|
-
return await
|
|
83
|
+
return await ctx.withPgClient(async (client) => {
|
|
115
84
|
const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
|
|
116
85
|
const result = await client.query(sql, [meterSlug, entityId, 1]);
|
|
117
86
|
return result.rows[0]?.allowed !== false;
|
|
@@ -123,9 +92,9 @@ async function checkQuota(pool, pgSettings, billing, entityId, meterSlug) {
|
|
|
123
92
|
return true;
|
|
124
93
|
}
|
|
125
94
|
}
|
|
126
|
-
async function recordUsage(
|
|
95
|
+
async function recordUsage(ctx, billing, entityId, meterSlug, amount, metadata) {
|
|
127
96
|
try {
|
|
128
|
-
await
|
|
97
|
+
await ctx.withPgClient(async (client) => {
|
|
129
98
|
const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
|
|
130
99
|
await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
|
|
131
100
|
});
|
|
@@ -135,11 +104,13 @@ async function recordUsage(pool, pgSettings, billing, entityId, meterSlug, amoun
|
|
|
135
104
|
log.warn(`[llm-api] record_usage failed (non-fatal): ${message}`);
|
|
136
105
|
}
|
|
137
106
|
}
|
|
138
|
-
async function resolveBilling(
|
|
107
|
+
async function resolveBilling(ctx) {
|
|
108
|
+
if (!ctx.databaseId)
|
|
109
|
+
return null;
|
|
139
110
|
try {
|
|
140
111
|
let billing = null;
|
|
141
|
-
await
|
|
142
|
-
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);
|
|
143
114
|
billing = entry.billing;
|
|
144
115
|
});
|
|
145
116
|
return billing;
|
|
@@ -178,9 +149,9 @@ async function getInferenceLogInfo(pool, dbname) {
|
|
|
178
149
|
inferenceLogCache.set(dbname, info);
|
|
179
150
|
return info;
|
|
180
151
|
}
|
|
181
|
-
async function logInference(
|
|
152
|
+
async function logInference(ctx, logInfo, data) {
|
|
182
153
|
try {
|
|
183
|
-
await
|
|
154
|
+
await ctx.withPgClient(async (client) => {
|
|
184
155
|
await client.query(`INSERT INTO "${logInfo.schemaName}"."${logInfo.tableName}"
|
|
185
156
|
(entity_id, actor_id, model, provider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status)
|
|
186
157
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
|
|
@@ -205,31 +176,29 @@ async function logInference(pool, pgSettings, logInfo, data) {
|
|
|
205
176
|
}
|
|
206
177
|
// ─── Route Handlers ─────────────────────────────────────────────────────────
|
|
207
178
|
async function handleCreateThread(req, res, entityId) {
|
|
208
|
-
|
|
179
|
+
const ctx = req.constructive;
|
|
180
|
+
if (!ctx?.userId) {
|
|
209
181
|
res.status(401).json({ error: 'Authentication required' });
|
|
210
182
|
return;
|
|
211
183
|
}
|
|
212
|
-
|
|
213
|
-
if (!dbname) {
|
|
184
|
+
if (!ctx.api.dbname) {
|
|
214
185
|
res.status(400).json({ error: 'Database not resolved' });
|
|
215
186
|
return;
|
|
216
187
|
}
|
|
217
|
-
const
|
|
218
|
-
const discovery = await (0, graphile_llm_1.getAgentDiscovery)(pool, dbname);
|
|
188
|
+
const discovery = await (0, graphile_llm_1.getAgentDiscovery)(ctx.pool, ctx.api.dbname);
|
|
219
189
|
if (!discovery?.thread) {
|
|
220
190
|
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
221
191
|
return;
|
|
222
192
|
}
|
|
223
193
|
const body = req.body || {};
|
|
224
194
|
const { thread } = discovery;
|
|
225
|
-
const
|
|
226
|
-
const result = await withRlsClient(pool, pgSettings, async (client) => {
|
|
195
|
+
const result = await ctx.withPgClient(async (client) => {
|
|
227
196
|
const { rows } = await client.query(`INSERT INTO "${thread.schemaName}"."${thread.tableName}"
|
|
228
197
|
(entity_id, owner_id, mode, model, system_prompt, title)
|
|
229
198
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
230
199
|
RETURNING id, mode, model, system_prompt, status, created_at`, [
|
|
231
200
|
entityId,
|
|
232
|
-
|
|
201
|
+
ctx.userId,
|
|
233
202
|
body.mode ?? 'ask',
|
|
234
203
|
body.model ?? null,
|
|
235
204
|
body.system_prompt ?? null,
|
|
@@ -247,17 +216,16 @@ async function handleCreateThread(req, res, entityId) {
|
|
|
247
216
|
});
|
|
248
217
|
}
|
|
249
218
|
async function handleSendMessage(req, res, entityId) {
|
|
250
|
-
|
|
219
|
+
const ctx = req.constructive;
|
|
220
|
+
if (!ctx?.userId) {
|
|
251
221
|
res.status(401).json({ error: 'Authentication required' });
|
|
252
222
|
return;
|
|
253
223
|
}
|
|
254
|
-
|
|
255
|
-
if (!dbname) {
|
|
224
|
+
if (!ctx.api.dbname) {
|
|
256
225
|
res.status(400).json({ error: 'Database not resolved' });
|
|
257
226
|
return;
|
|
258
227
|
}
|
|
259
|
-
const
|
|
260
|
-
const discovery = await (0, graphile_llm_1.getAgentDiscovery)(pool, dbname);
|
|
228
|
+
const discovery = await (0, graphile_llm_1.getAgentDiscovery)(ctx.pool, ctx.api.dbname);
|
|
261
229
|
if (!discovery?.thread || !discovery?.message) {
|
|
262
230
|
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
263
231
|
return;
|
|
@@ -268,12 +236,10 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
268
236
|
return;
|
|
269
237
|
}
|
|
270
238
|
const { thread, message: msgTable } = discovery;
|
|
271
|
-
const pgSettings = getPgSettings(req);
|
|
272
239
|
const threadId = req.params.thread_id;
|
|
273
|
-
const userId =
|
|
274
|
-
const databaseId = req.databaseId;
|
|
240
|
+
const userId = ctx.userId;
|
|
275
241
|
// 1. Verify thread exists and user owns it (RLS enforced)
|
|
276
|
-
const threadRow = await
|
|
242
|
+
const threadRow = await ctx.withPgClient(async (client) => {
|
|
277
243
|
const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
|
|
278
244
|
FROM "${thread.schemaName}"."${thread.tableName}"
|
|
279
245
|
WHERE id = $1`, [threadId]);
|
|
@@ -284,10 +250,8 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
284
250
|
return;
|
|
285
251
|
}
|
|
286
252
|
// 2. Resolve billing config + inference log discovery
|
|
287
|
-
const billing =
|
|
288
|
-
|
|
289
|
-
: null;
|
|
290
|
-
const inferenceLog = await getInferenceLogInfo(pool, dbname);
|
|
253
|
+
const billing = await resolveBilling(ctx);
|
|
254
|
+
const inferenceLog = await getInferenceLogInfo(ctx.pool, ctx.api.dbname);
|
|
291
255
|
const ollama = resolveOllamaAdapter();
|
|
292
256
|
if (!ollama) {
|
|
293
257
|
res.status(503).json({ error: 'No LLM provider configured' });
|
|
@@ -296,7 +260,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
296
260
|
const model = body.model ?? threadRow.model ?? ollama.model;
|
|
297
261
|
const meterSlug = model;
|
|
298
262
|
if (billing) {
|
|
299
|
-
const allowed = await checkQuota(
|
|
263
|
+
const allowed = await checkQuota(ctx, billing, entityId, meterSlug);
|
|
300
264
|
if (!allowed) {
|
|
301
265
|
res.status(429).json({
|
|
302
266
|
error: 'Token quota exceeded',
|
|
@@ -307,7 +271,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
307
271
|
}
|
|
308
272
|
}
|
|
309
273
|
// 3. Persist user message(s)
|
|
310
|
-
await
|
|
274
|
+
await ctx.withPgClient(async (client) => {
|
|
311
275
|
for (const msg of body.messages) {
|
|
312
276
|
if (msg.role === 'user') {
|
|
313
277
|
await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
@@ -322,7 +286,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
322
286
|
}
|
|
323
287
|
});
|
|
324
288
|
// 4. Load full thread history for context
|
|
325
|
-
const history = await
|
|
289
|
+
const history = await ctx.withPgClient(async (client) => {
|
|
326
290
|
const { rows } = await client.query(`SELECT author_role, parts, created_at
|
|
327
291
|
FROM "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
328
292
|
WHERE thread_id = $1
|
|
@@ -408,7 +372,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
408
372
|
res.end();
|
|
409
373
|
// 6. Persist assistant message with model (fire-and-forget)
|
|
410
374
|
if (content) {
|
|
411
|
-
|
|
375
|
+
ctx.withPgClient(async (client) => {
|
|
412
376
|
await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
413
377
|
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
414
378
|
VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
|
|
@@ -424,7 +388,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
424
388
|
}
|
|
425
389
|
// 7. Record billing usage (fire-and-forget)
|
|
426
390
|
if (billing && usage.totalTokens > 0) {
|
|
427
|
-
recordUsage(
|
|
391
|
+
recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
|
|
428
392
|
input_tokens: usage.input,
|
|
429
393
|
output_tokens: usage.output,
|
|
430
394
|
cache_read_tokens: usage.cacheRead,
|
|
@@ -436,7 +400,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
436
400
|
}
|
|
437
401
|
// 8. Inference log (fire-and-forget)
|
|
438
402
|
if (inferenceLog) {
|
|
439
|
-
logInference(
|
|
403
|
+
logInference(ctx, inferenceLog, {
|
|
440
404
|
entityId,
|
|
441
405
|
actorId: userId,
|
|
442
406
|
model,
|
|
@@ -492,7 +456,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
492
456
|
totalTokens: result.usage.totalTokens,
|
|
493
457
|
};
|
|
494
458
|
// Persist assistant message with model
|
|
495
|
-
await
|
|
459
|
+
await ctx.withPgClient(async (client) => {
|
|
496
460
|
await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
497
461
|
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
498
462
|
VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
|
|
@@ -505,7 +469,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
505
469
|
});
|
|
506
470
|
// Record billing usage
|
|
507
471
|
if (billing && usage.totalTokens > 0) {
|
|
508
|
-
recordUsage(
|
|
472
|
+
recordUsage(ctx, billing, entityId, meterSlug, usage.totalTokens, {
|
|
509
473
|
input_tokens: usage.input,
|
|
510
474
|
output_tokens: usage.output,
|
|
511
475
|
cache_read_tokens: usage.cacheRead,
|
|
@@ -517,7 +481,7 @@ async function handleSendMessage(req, res, entityId) {
|
|
|
517
481
|
}
|
|
518
482
|
// Inference log
|
|
519
483
|
if (inferenceLog) {
|
|
520
|
-
logInference(
|
|
484
|
+
logInference(ctx, inferenceLog, {
|
|
521
485
|
entityId,
|
|
522
486
|
actorId: userId,
|
|
523
487
|
model,
|
|
@@ -577,7 +541,7 @@ function createLlmApiRouter() {
|
|
|
577
541
|
// ── Global routes (no entity_id — bills to actor_id from JWT) ────────────
|
|
578
542
|
router.post('/v1/threads', async (req, res) => {
|
|
579
543
|
try {
|
|
580
|
-
const userId = req.
|
|
544
|
+
const userId = req.constructive?.userId;
|
|
581
545
|
if (!userId) {
|
|
582
546
|
res.status(401).json({ error: 'Authentication required' });
|
|
583
547
|
return;
|
|
@@ -593,7 +557,7 @@ function createLlmApiRouter() {
|
|
|
593
557
|
});
|
|
594
558
|
router.post('/v1/threads/:thread_id/messages', async (req, res) => {
|
|
595
559
|
try {
|
|
596
|
-
const userId = req.
|
|
560
|
+
const userId = req.constructive?.userId;
|
|
597
561
|
if (!userId) {
|
|
598
562
|
res.status(401).json({ error: 'Authentication required' });
|
|
599
563
|
return;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.34.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -43,16 +43,17 @@
|
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@agentic-kit/ollama": "^2.0.0",
|
|
45
45
|
"@constructive-io/csrf": "^0.14.0",
|
|
46
|
-
"@constructive-io/
|
|
47
|
-
"@constructive-io/graphql-
|
|
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.
|
|
53
|
+
"@pgpmjs/env": "^2.24.0",
|
|
53
54
|
"@pgpmjs/logger": "^2.11.0",
|
|
54
|
-
"@pgpmjs/server-utils": "^3.
|
|
55
|
-
"@pgpmjs/types": "^2.
|
|
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.
|
|
66
|
+
"graphile-cache": "^3.12.0",
|
|
66
67
|
"graphile-config": "1.0.1",
|
|
67
|
-
"graphile-llm": "^0.
|
|
68
|
-
"graphile-settings": "^5.
|
|
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.
|
|
76
|
-
"pg-env": "^1.
|
|
77
|
-
"pg-query-context": "^2.
|
|
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.
|
|
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": "
|
|
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
|