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