@constructive-io/graphql-server 5.14.5 → 5.15.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/agentic/index.d.ts +25 -0
- package/agentic/index.js +30 -0
- package/agentic/router.d.ts +21 -0
- package/agentic/router.js +522 -0
- package/esm/agentic/index.js +23 -0
- package/esm/agentic/router.js +486 -0
- package/esm/middleware/graphile.js +11 -6
- package/esm/server.js +1 -1
- package/middleware/graphile.js +10 -5
- package/package.json +10 -7
- package/server.js +2 -2
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent threads REST API — server-internal Express router
|
|
3
|
+
*
|
|
4
|
+
* Stateful agent-thread service (chat streaming, billing metering, inference
|
|
5
|
+
* logging) mounted by the GraphQL server. Uses @constructive-io/express-context
|
|
6
|
+
* for tenant-scoped database access.
|
|
7
|
+
*
|
|
8
|
+
* LLM provider config is resolved per-database via `ctx.useLlm()` (from the
|
|
9
|
+
* llm_module table), falling back to env vars from @constructive-io/llm-env when the module
|
|
10
|
+
* is not provisioned. Discovery and billing are handled by the shared loaders
|
|
11
|
+
* in express-context — no custom caching here.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { createContextMiddleware } from '@constructive-io/express-context';
|
|
16
|
+
* import { createAgenticRouter } from './agentic';
|
|
17
|
+
*
|
|
18
|
+
* app.use(createContextMiddleware());
|
|
19
|
+
* app.use(createAgenticRouter());
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export { createAgenticRouter } from './router';
|
|
23
|
+
export type { BillingClient, InferenceLogEntry, LlmConfig } from '@constructive-io/express-context';
|
|
24
|
+
export type { LlmEnvOptions, LlmProviderConfig, ResolvedLlmEnvOptions } from '@constructive-io/llm-env';
|
|
25
|
+
export { getEnvOptions as getLlmEnvOptions, getEnvVars as getLlmEnvVars, llmDefaults } from '@constructive-io/llm-env';
|
package/agentic/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Agent threads REST API — server-internal Express router
|
|
4
|
+
*
|
|
5
|
+
* Stateful agent-thread service (chat streaming, billing metering, inference
|
|
6
|
+
* logging) mounted by the GraphQL server. Uses @constructive-io/express-context
|
|
7
|
+
* for tenant-scoped database access.
|
|
8
|
+
*
|
|
9
|
+
* LLM provider config is resolved per-database via `ctx.useLlm()` (from the
|
|
10
|
+
* llm_module table), falling back to env vars from @constructive-io/llm-env when the module
|
|
11
|
+
* is not provisioned. Discovery and billing are handled by the shared loaders
|
|
12
|
+
* in express-context — no custom caching here.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { createContextMiddleware } from '@constructive-io/express-context';
|
|
17
|
+
* import { createAgenticRouter } from './agentic';
|
|
18
|
+
*
|
|
19
|
+
* app.use(createContextMiddleware());
|
|
20
|
+
* app.use(createAgenticRouter());
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
|
+
exports.llmDefaults = exports.getLlmEnvVars = exports.getLlmEnvOptions = exports.createAgenticRouter = void 0;
|
|
25
|
+
var router_1 = require("./router");
|
|
26
|
+
Object.defineProperty(exports, "createAgenticRouter", { enumerable: true, get: function () { return router_1.createAgenticRouter; } });
|
|
27
|
+
var llm_env_1 = require("@constructive-io/llm-env");
|
|
28
|
+
Object.defineProperty(exports, "getLlmEnvOptions", { enumerable: true, get: function () { return llm_env_1.getEnvOptions; } });
|
|
29
|
+
Object.defineProperty(exports, "getLlmEnvVars", { enumerable: true, get: function () { return llm_env_1.getEnvVars; } });
|
|
30
|
+
Object.defineProperty(exports, "llmDefaults", { enumerable: true, get: function () { return llm_env_1.llmDefaults; } });
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router — Express router for the agentic-server
|
|
3
|
+
*
|
|
4
|
+
* Provides REST endpoints for AI agent conversations:
|
|
5
|
+
*
|
|
6
|
+
* POST /v1/threads → create thread
|
|
7
|
+
* POST /v1/threads/:thread_id/messages → send message + stream response
|
|
8
|
+
* POST /v1/orgs/:entity_id/threads → create thread (entity-scoped)
|
|
9
|
+
* POST /v1/orgs/:entity_id/threads/:thread_id/messages → send message (entity-scoped)
|
|
10
|
+
* POST /v1/embed → generate embedding
|
|
11
|
+
*
|
|
12
|
+
* All routes require `req.constructive` (from @constructive-io/express-context).
|
|
13
|
+
* Billing (check_quota + record_usage) and inference logging are automatic
|
|
14
|
+
* when the billing/inference_log modules are provisioned.
|
|
15
|
+
*
|
|
16
|
+
* LLM provider config is resolved per-database via `ctx.useLlm()` from the
|
|
17
|
+
* llm_module table, falling back to env vars (EMBEDDER_*, CHAT_*) when the
|
|
18
|
+
* module is not provisioned.
|
|
19
|
+
*/
|
|
20
|
+
import { Router } from 'express';
|
|
21
|
+
export declare function createAgenticRouter(): Router;
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* router — Express router for the agentic-server
|
|
4
|
+
*
|
|
5
|
+
* Provides REST endpoints for AI agent conversations:
|
|
6
|
+
*
|
|
7
|
+
* POST /v1/threads → create thread
|
|
8
|
+
* POST /v1/threads/:thread_id/messages → send message + stream response
|
|
9
|
+
* POST /v1/orgs/:entity_id/threads → create thread (entity-scoped)
|
|
10
|
+
* POST /v1/orgs/:entity_id/threads/:thread_id/messages → send message (entity-scoped)
|
|
11
|
+
* POST /v1/embed → generate embedding
|
|
12
|
+
*
|
|
13
|
+
* All routes require `req.constructive` (from @constructive-io/express-context).
|
|
14
|
+
* Billing (check_quota + record_usage) and inference logging are automatic
|
|
15
|
+
* when the billing/inference_log modules are provisioned.
|
|
16
|
+
*
|
|
17
|
+
* LLM provider config is resolved per-database via `ctx.useLlm()` from the
|
|
18
|
+
* llm_module table, falling back to env vars (EMBEDDER_*, CHAT_*) when the
|
|
19
|
+
* module is not provisioned.
|
|
20
|
+
*/
|
|
21
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
24
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
25
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
26
|
+
}
|
|
27
|
+
Object.defineProperty(o, k2, desc);
|
|
28
|
+
}) : (function(o, m, k, k2) {
|
|
29
|
+
if (k2 === undefined) k2 = k;
|
|
30
|
+
o[k2] = m[k];
|
|
31
|
+
}));
|
|
32
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
33
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
34
|
+
}) : function(o, v) {
|
|
35
|
+
o["default"] = v;
|
|
36
|
+
});
|
|
37
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
38
|
+
var ownKeys = function(o) {
|
|
39
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
40
|
+
var ar = [];
|
|
41
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
42
|
+
return ar;
|
|
43
|
+
};
|
|
44
|
+
return ownKeys(o);
|
|
45
|
+
};
|
|
46
|
+
return function (mod) {
|
|
47
|
+
if (mod && mod.__esModule) return mod;
|
|
48
|
+
var result = {};
|
|
49
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
50
|
+
__setModuleDefault(result, mod);
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
})();
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.createAgenticRouter = createAgenticRouter;
|
|
56
|
+
const ollama_1 = require("@agentic-kit/ollama");
|
|
57
|
+
const llm_env_1 = require("@constructive-io/llm-env");
|
|
58
|
+
const logger_1 = require("@pgpmjs/logger");
|
|
59
|
+
const express_1 = __importStar(require("express"));
|
|
60
|
+
const log = new logger_1.Logger('agentic-server');
|
|
61
|
+
function resolveChatAdapter(llm) {
|
|
62
|
+
const provider = llm?.chatProvider ?? (0, llm_env_1.getEnvOptions)().chat.provider;
|
|
63
|
+
const model = llm?.chatModel ?? (0, llm_env_1.getEnvOptions)().chat.model;
|
|
64
|
+
const baseUrl = llm?.chatBaseUrl ?? (0, llm_env_1.getEnvOptions)().chat.baseUrl;
|
|
65
|
+
if (provider === 'ollama') {
|
|
66
|
+
return { adapter: new ollama_1.OllamaAdapter(baseUrl), model, baseUrl, provider };
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
function resolveEmbeddingAdapter(llm) {
|
|
71
|
+
const provider = llm?.embeddingProvider ?? (0, llm_env_1.getEnvOptions)().embedding.provider;
|
|
72
|
+
const model = llm?.embeddingModel ?? (0, llm_env_1.getEnvOptions)().embedding.model;
|
|
73
|
+
const baseUrl = llm?.embeddingBaseUrl ?? (0, llm_env_1.getEnvOptions)().embedding.baseUrl;
|
|
74
|
+
if (provider === 'ollama') {
|
|
75
|
+
return { adapter: new ollama_1.OllamaAdapter(baseUrl), model, provider };
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
// ─── Route Handlers ─────────────────────────────────────────────────────────
|
|
80
|
+
async function handleCreateThread(req, res, entityId) {
|
|
81
|
+
const ctx = req.constructive;
|
|
82
|
+
if (!ctx?.userId) {
|
|
83
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const agentChat = await ctx.useModule('agentChat');
|
|
87
|
+
if (!agentChat?.threadTableName) {
|
|
88
|
+
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const body = req.body || {};
|
|
92
|
+
const { schemaName, threadTableName } = agentChat;
|
|
93
|
+
const result = await ctx.withPgClient(async (client) => {
|
|
94
|
+
const { rows } = await client.query(`INSERT INTO "${schemaName}"."${threadTableName}"
|
|
95
|
+
(entity_id, owner_id, mode, model, system_prompt, title)
|
|
96
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
97
|
+
RETURNING id, mode, model, system_prompt, status, created_at`, [
|
|
98
|
+
entityId,
|
|
99
|
+
ctx.userId,
|
|
100
|
+
body.mode ?? 'ask',
|
|
101
|
+
body.model ?? null,
|
|
102
|
+
body.system_prompt ?? null,
|
|
103
|
+
body.title ?? null
|
|
104
|
+
]);
|
|
105
|
+
return rows[0];
|
|
106
|
+
});
|
|
107
|
+
res.status(201).json({
|
|
108
|
+
id: result.id,
|
|
109
|
+
mode: result.mode,
|
|
110
|
+
model: result.model,
|
|
111
|
+
system_prompt: result.system_prompt,
|
|
112
|
+
status: result.status,
|
|
113
|
+
created_at: result.created_at
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
async function handleSendMessage(req, res, entityId) {
|
|
117
|
+
const ctx = req.constructive;
|
|
118
|
+
if (!ctx?.userId) {
|
|
119
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const agentChat = await ctx.useModule('agentChat');
|
|
123
|
+
if (!agentChat?.threadTableName || !agentChat?.messageTableName) {
|
|
124
|
+
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const body = req.body || {};
|
|
128
|
+
if (!body.messages?.length) {
|
|
129
|
+
res.status(400).json({ error: 'messages[] is required and must not be empty' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const { schemaName, threadTableName, messageTableName } = agentChat;
|
|
133
|
+
const threadId = req.params.thread_id;
|
|
134
|
+
const userId = ctx.userId;
|
|
135
|
+
// Verify thread exists (RLS enforced)
|
|
136
|
+
const threadRow = await ctx.withPgClient(async (client) => {
|
|
137
|
+
const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
|
|
138
|
+
FROM "${schemaName}"."${threadTableName}"
|
|
139
|
+
WHERE id = $1`, [threadId]);
|
|
140
|
+
return rows[0];
|
|
141
|
+
});
|
|
142
|
+
if (!threadRow) {
|
|
143
|
+
res.status(404).json({ error: 'Thread not found' });
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// Resolve shared billing client and LLM config (lazy, cached per request)
|
|
147
|
+
const [billing, llm] = await Promise.all([ctx.useBilling(), ctx.useLlm()]);
|
|
148
|
+
const chatAdapter = resolveChatAdapter(llm);
|
|
149
|
+
if (!chatAdapter) {
|
|
150
|
+
res.status(503).json({ error: 'No LLM provider configured' });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const model = body.model ?? threadRow.model ?? chatAdapter.model;
|
|
154
|
+
const meterSlug = model;
|
|
155
|
+
// Quota check
|
|
156
|
+
if (billing) {
|
|
157
|
+
const allowed = await billing.checkQuota(meterSlug);
|
|
158
|
+
if (!allowed) {
|
|
159
|
+
res.status(429).json({
|
|
160
|
+
error: 'Token quota exceeded',
|
|
161
|
+
meter: meterSlug,
|
|
162
|
+
entity_id: entityId
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
// Persist user messages
|
|
168
|
+
await ctx.withPgClient(async (client) => {
|
|
169
|
+
for (const msg of body.messages) {
|
|
170
|
+
if (msg.role === 'user') {
|
|
171
|
+
await client.query(`INSERT INTO "${schemaName}"."${messageTableName}"
|
|
172
|
+
(thread_id, owner_id, entity_id, author_role, parts)
|
|
173
|
+
VALUES ($1, $2, (SELECT entity_id FROM "${schemaName}"."${threadTableName}" WHERE id = $1), $3, $4)`, [threadId, userId, 'user', JSON.stringify([{ type: 'text', text: msg.content }])]);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
// Load full thread history
|
|
178
|
+
const history = await ctx.withPgClient(async (client) => {
|
|
179
|
+
const { rows } = await client.query(`SELECT author_role, parts, created_at
|
|
180
|
+
FROM "${schemaName}"."${messageTableName}"
|
|
181
|
+
WHERE thread_id = $1
|
|
182
|
+
ORDER BY created_at ASC`, [threadId]);
|
|
183
|
+
return rows;
|
|
184
|
+
});
|
|
185
|
+
const llmMessages = [];
|
|
186
|
+
if (threadRow.system_prompt) {
|
|
187
|
+
llmMessages.push({ role: 'system', content: threadRow.system_prompt });
|
|
188
|
+
}
|
|
189
|
+
for (const row of history) {
|
|
190
|
+
const parts = Array.isArray(row.parts) ? row.parts : [];
|
|
191
|
+
const textContent = parts
|
|
192
|
+
.filter((p) => p.type === 'text')
|
|
193
|
+
.map((p) => p.text)
|
|
194
|
+
.join('');
|
|
195
|
+
if (textContent) {
|
|
196
|
+
llmMessages.push({
|
|
197
|
+
role: row.author_role === 'user' ? 'user' : 'assistant',
|
|
198
|
+
content: textContent
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const startTime = Date.now();
|
|
203
|
+
const shouldStream = body.stream !== false;
|
|
204
|
+
if (shouldStream) {
|
|
205
|
+
await handleStreamingResponse(req, res, {
|
|
206
|
+
ctx, chatAdapter, model, llmMessages, body,
|
|
207
|
+
entityId, userId, threadId,
|
|
208
|
+
schemaName, threadTableName, messageTableName,
|
|
209
|
+
billing, startTime, meterSlug
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
await handleBatchResponse(req, res, {
|
|
214
|
+
ctx, chatAdapter, model, llmMessages, body,
|
|
215
|
+
entityId, userId, threadId,
|
|
216
|
+
schemaName, threadTableName, messageTableName,
|
|
217
|
+
billing, startTime, meterSlug
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async function handleStreamingResponse(_req, res, mc) {
|
|
222
|
+
const { ctx, chatAdapter, model, llmMessages, body, entityId, userId, threadId, schemaName, threadTableName, messageTableName, billing, startTime, meterSlug } = mc;
|
|
223
|
+
res.writeHead(200, {
|
|
224
|
+
'Content-Type': 'text/event-stream',
|
|
225
|
+
'Cache-Control': 'no-cache',
|
|
226
|
+
Connection: 'keep-alive',
|
|
227
|
+
'X-Accel-Buffering': 'no'
|
|
228
|
+
});
|
|
229
|
+
const messageId = `msg_${Date.now()}`;
|
|
230
|
+
try {
|
|
231
|
+
const systemMsg = llmMessages.find(m => m.role === 'system');
|
|
232
|
+
const nonSystem = llmMessages.filter(m => m.role !== 'system');
|
|
233
|
+
const modelDesc = chatAdapter.adapter.createModel(model, { maxOutputTokens: undefined });
|
|
234
|
+
const context = {
|
|
235
|
+
systemPrompt: systemMsg?.content,
|
|
236
|
+
messages: nonSystem.map((m) => ({
|
|
237
|
+
role: m.role,
|
|
238
|
+
content: m.content,
|
|
239
|
+
timestamp: Date.now()
|
|
240
|
+
}))
|
|
241
|
+
};
|
|
242
|
+
const stream = chatAdapter.adapter.stream(modelDesc, context, {
|
|
243
|
+
temperature: body.temperature
|
|
244
|
+
});
|
|
245
|
+
let streamedContent = '';
|
|
246
|
+
for await (const event of stream) {
|
|
247
|
+
if (event.type === 'text_delta') {
|
|
248
|
+
streamedContent += event.delta;
|
|
249
|
+
const sseEvent = {
|
|
250
|
+
id: messageId,
|
|
251
|
+
choices: [{
|
|
252
|
+
index: 0,
|
|
253
|
+
delta: { content: event.delta, role: 'assistant' },
|
|
254
|
+
finish_reason: null
|
|
255
|
+
}],
|
|
256
|
+
model
|
|
257
|
+
};
|
|
258
|
+
res.write(`data: ${JSON.stringify(sseEvent)}\n\n`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const result = await stream.result();
|
|
262
|
+
const content = streamedContent;
|
|
263
|
+
const latencyMs = Date.now() - startTime;
|
|
264
|
+
const usage = {
|
|
265
|
+
input: result.usage.input,
|
|
266
|
+
output: result.usage.output,
|
|
267
|
+
reasoning: result.usage.reasoning,
|
|
268
|
+
cacheRead: result.usage.cacheRead,
|
|
269
|
+
cacheWrite: result.usage.cacheWrite,
|
|
270
|
+
totalTokens: result.usage.totalTokens
|
|
271
|
+
};
|
|
272
|
+
res.write('data: [DONE]\n\n');
|
|
273
|
+
res.end();
|
|
274
|
+
// Persist assistant message (fire-and-forget)
|
|
275
|
+
if (content) {
|
|
276
|
+
ctx.withPgClient(async (client) => {
|
|
277
|
+
await client.query(`INSERT INTO "${schemaName}"."${messageTableName}"
|
|
278
|
+
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
279
|
+
VALUES ($1, $2, (SELECT entity_id FROM "${schemaName}"."${threadTableName}" WHERE id = $1), $3, $4, $5)`, [threadId, userId, 'assistant', JSON.stringify([{ type: 'text', text: content }]), model]);
|
|
280
|
+
}).catch((err) => log.error('Failed to persist assistant message:', err));
|
|
281
|
+
}
|
|
282
|
+
// Record billing usage + inference log (fire-and-forget)
|
|
283
|
+
if (billing && usage.totalTokens > 0) {
|
|
284
|
+
billing.recordUsage(meterSlug, usage.totalTokens, {
|
|
285
|
+
input_tokens: usage.input,
|
|
286
|
+
output_tokens: usage.output,
|
|
287
|
+
cache_read_tokens: usage.cacheRead,
|
|
288
|
+
cache_write_tokens: usage.cacheWrite,
|
|
289
|
+
model,
|
|
290
|
+
latency_ms: latencyMs,
|
|
291
|
+
stream: true
|
|
292
|
+
}).catch(() => { });
|
|
293
|
+
billing.logInference({
|
|
294
|
+
entityId, actorId: userId, model, provider: chatAdapter.provider,
|
|
295
|
+
service: 'llm', operation: 'chat',
|
|
296
|
+
inputTokens: usage.input, outputTokens: usage.output,
|
|
297
|
+
totalTokens: usage.totalTokens, latencyMs, status: 'ok'
|
|
298
|
+
}).catch(() => { });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch (streamErr) {
|
|
302
|
+
log.error('Streaming error:', streamErr);
|
|
303
|
+
const errorEvent = { error: { message: streamErr.message, type: 'stream_error' } };
|
|
304
|
+
res.write(`data: ${JSON.stringify(errorEvent)}\n\n`);
|
|
305
|
+
res.write('data: [DONE]\n\n');
|
|
306
|
+
res.end();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
async function handleBatchResponse(_req, res, mc) {
|
|
310
|
+
const { ctx, chatAdapter, model, llmMessages, body, entityId, userId, threadId, schemaName, threadTableName, messageTableName, billing, startTime, meterSlug } = mc;
|
|
311
|
+
const systemMsg = llmMessages.find(m => m.role === 'system');
|
|
312
|
+
const nonSystem = llmMessages.filter(m => m.role !== 'system');
|
|
313
|
+
const modelDesc = chatAdapter.adapter.createModel(model, { maxOutputTokens: undefined });
|
|
314
|
+
const context = {
|
|
315
|
+
systemPrompt: systemMsg?.content,
|
|
316
|
+
messages: nonSystem.map((m) => ({
|
|
317
|
+
role: m.role,
|
|
318
|
+
content: m.content,
|
|
319
|
+
timestamp: Date.now()
|
|
320
|
+
}))
|
|
321
|
+
};
|
|
322
|
+
const stream = chatAdapter.adapter.stream(modelDesc, context, {
|
|
323
|
+
temperature: body.temperature
|
|
324
|
+
});
|
|
325
|
+
const result = await stream.result();
|
|
326
|
+
const content = result.content
|
|
327
|
+
.filter((block) => block.type === 'text')
|
|
328
|
+
.map((block) => block.text)
|
|
329
|
+
.join('');
|
|
330
|
+
const latencyMs = Date.now() - startTime;
|
|
331
|
+
const usage = {
|
|
332
|
+
input: result.usage.input,
|
|
333
|
+
output: result.usage.output,
|
|
334
|
+
reasoning: result.usage.reasoning,
|
|
335
|
+
cacheRead: result.usage.cacheRead,
|
|
336
|
+
cacheWrite: result.usage.cacheWrite,
|
|
337
|
+
totalTokens: result.usage.totalTokens
|
|
338
|
+
};
|
|
339
|
+
// Persist assistant message
|
|
340
|
+
await ctx.withPgClient(async (client) => {
|
|
341
|
+
await client.query(`INSERT INTO "${schemaName}"."${messageTableName}"
|
|
342
|
+
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
343
|
+
VALUES ($1, $2, (SELECT entity_id FROM "${schemaName}"."${threadTableName}" WHERE id = $1), $3, $4, $5)`, [threadId, userId, 'assistant', JSON.stringify([{ type: 'text', text: content }]), model]);
|
|
344
|
+
});
|
|
345
|
+
// Record billing + inference log (fire-and-forget)
|
|
346
|
+
if (billing && usage.totalTokens > 0) {
|
|
347
|
+
billing.recordUsage(meterSlug, usage.totalTokens, {
|
|
348
|
+
input_tokens: usage.input,
|
|
349
|
+
output_tokens: usage.output,
|
|
350
|
+
cache_read_tokens: usage.cacheRead,
|
|
351
|
+
cache_write_tokens: usage.cacheWrite,
|
|
352
|
+
model,
|
|
353
|
+
latency_ms: latencyMs,
|
|
354
|
+
stream: false
|
|
355
|
+
}).catch(() => { });
|
|
356
|
+
billing.logInference({
|
|
357
|
+
entityId, actorId: userId, model, provider: chatAdapter.provider,
|
|
358
|
+
service: 'llm', operation: 'chat',
|
|
359
|
+
inputTokens: usage.input, outputTokens: usage.output,
|
|
360
|
+
totalTokens: usage.totalTokens, latencyMs, status: 'ok'
|
|
361
|
+
}).catch(() => { });
|
|
362
|
+
}
|
|
363
|
+
res.json({
|
|
364
|
+
id: `msg_${Date.now()}`,
|
|
365
|
+
choices: [{
|
|
366
|
+
index: 0,
|
|
367
|
+
message: { role: 'assistant', content },
|
|
368
|
+
finish_reason: 'stop'
|
|
369
|
+
}],
|
|
370
|
+
model,
|
|
371
|
+
usage: {
|
|
372
|
+
prompt_tokens: usage.input,
|
|
373
|
+
completion_tokens: usage.output,
|
|
374
|
+
total_tokens: usage.totalTokens
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
// ─── Embedding Handler ──────────────────────────────────────────────────────
|
|
379
|
+
async function handleEmbed(req, res) {
|
|
380
|
+
const ctx = req.constructive;
|
|
381
|
+
if (!ctx?.userId) {
|
|
382
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const body = req.body || {};
|
|
386
|
+
if (!body.input) {
|
|
387
|
+
res.status(400).json({ error: 'input is required' });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const llm = await ctx.useLlm();
|
|
391
|
+
const embedAdapter = resolveEmbeddingAdapter(llm);
|
|
392
|
+
if (!embedAdapter) {
|
|
393
|
+
res.status(503).json({ error: 'No embedding provider configured' });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const model = body.model ?? embedAdapter.model;
|
|
397
|
+
const inputs = Array.isArray(body.input) ? body.input : [body.input];
|
|
398
|
+
// Resolve shared billing client
|
|
399
|
+
const billing = await ctx.useBilling();
|
|
400
|
+
// Quota check
|
|
401
|
+
if (billing) {
|
|
402
|
+
const allowed = await billing.checkQuota(model);
|
|
403
|
+
if (!allowed) {
|
|
404
|
+
res.status(429).json({ error: 'Embedding quota exceeded', meter: model });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
const startTime = Date.now();
|
|
409
|
+
try {
|
|
410
|
+
const results = await Promise.all(inputs.map((text) => embedAdapter.adapter.embed(text, model)));
|
|
411
|
+
const latencyMs = Date.now() - startTime;
|
|
412
|
+
const totalTokens = results.reduce((sum, r) => sum + r.promptTokens, 0);
|
|
413
|
+
// Record usage + inference log (fire-and-forget)
|
|
414
|
+
if (billing && totalTokens > 0) {
|
|
415
|
+
billing.recordUsage(model, totalTokens, {
|
|
416
|
+
input_tokens: totalTokens,
|
|
417
|
+
model,
|
|
418
|
+
latency_ms: latencyMs,
|
|
419
|
+
batch_size: inputs.length
|
|
420
|
+
}).catch(() => { });
|
|
421
|
+
billing.logInference({
|
|
422
|
+
entityId: ctx.userId,
|
|
423
|
+
actorId: ctx.userId,
|
|
424
|
+
model,
|
|
425
|
+
provider: embedAdapter.provider,
|
|
426
|
+
service: 'embedding',
|
|
427
|
+
operation: 'embed',
|
|
428
|
+
inputTokens: totalTokens,
|
|
429
|
+
outputTokens: 0,
|
|
430
|
+
totalTokens,
|
|
431
|
+
latencyMs,
|
|
432
|
+
status: 'ok'
|
|
433
|
+
}).catch(() => { });
|
|
434
|
+
}
|
|
435
|
+
res.json({
|
|
436
|
+
object: 'list',
|
|
437
|
+
data: results.map((r, i) => ({
|
|
438
|
+
object: 'embedding',
|
|
439
|
+
index: i,
|
|
440
|
+
embedding: r.embedding
|
|
441
|
+
})),
|
|
442
|
+
model,
|
|
443
|
+
usage: {
|
|
444
|
+
prompt_tokens: totalTokens,
|
|
445
|
+
total_tokens: totalTokens
|
|
446
|
+
}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
catch (err) {
|
|
450
|
+
log.error('Embedding error:', err);
|
|
451
|
+
res.status(500).json({ error: err.message ?? 'Embedding failed' });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
// ─── Router Factory ─────────────────────────────────────────────────────────
|
|
455
|
+
function createAgenticRouter() {
|
|
456
|
+
const router = (0, express_1.Router)();
|
|
457
|
+
router.use(express_1.default.json());
|
|
458
|
+
// Entity-scoped routes
|
|
459
|
+
router.post('/v1/orgs/:entity_id/threads', async (req, res) => {
|
|
460
|
+
try {
|
|
461
|
+
await handleCreateThread(req, res, req.params.entity_id);
|
|
462
|
+
}
|
|
463
|
+
catch (err) {
|
|
464
|
+
log.error('Error creating thread:', err);
|
|
465
|
+
if (!res.headersSent)
|
|
466
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
router.post('/v1/orgs/:entity_id/threads/:thread_id/messages', async (req, res) => {
|
|
470
|
+
try {
|
|
471
|
+
await handleSendMessage(req, res, req.params.entity_id);
|
|
472
|
+
}
|
|
473
|
+
catch (err) {
|
|
474
|
+
log.error('Error in messages endpoint:', err);
|
|
475
|
+
if (!res.headersSent)
|
|
476
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
// Global routes (entity_id = user_id from JWT)
|
|
480
|
+
router.post('/v1/threads', async (req, res) => {
|
|
481
|
+
try {
|
|
482
|
+
const userId = req.constructive?.userId;
|
|
483
|
+
if (!userId) {
|
|
484
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
await handleCreateThread(req, res, userId);
|
|
488
|
+
}
|
|
489
|
+
catch (err) {
|
|
490
|
+
log.error('Error creating thread:', err);
|
|
491
|
+
if (!res.headersSent)
|
|
492
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
router.post('/v1/threads/:thread_id/messages', async (req, res) => {
|
|
496
|
+
try {
|
|
497
|
+
const userId = req.constructive?.userId;
|
|
498
|
+
if (!userId) {
|
|
499
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
await handleSendMessage(req, res, userId);
|
|
503
|
+
}
|
|
504
|
+
catch (err) {
|
|
505
|
+
log.error('Error in messages endpoint:', err);
|
|
506
|
+
if (!res.headersSent)
|
|
507
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
// Embedding endpoint
|
|
511
|
+
router.post('/v1/embed', async (req, res) => {
|
|
512
|
+
try {
|
|
513
|
+
await handleEmbed(req, res);
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
log.error('Error in embed endpoint:', err);
|
|
517
|
+
if (!res.headersSent)
|
|
518
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
return router;
|
|
522
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent threads REST API — server-internal Express router
|
|
3
|
+
*
|
|
4
|
+
* Stateful agent-thread service (chat streaming, billing metering, inference
|
|
5
|
+
* logging) mounted by the GraphQL server. Uses @constructive-io/express-context
|
|
6
|
+
* for tenant-scoped database access.
|
|
7
|
+
*
|
|
8
|
+
* LLM provider config is resolved per-database via `ctx.useLlm()` (from the
|
|
9
|
+
* llm_module table), falling back to env vars from @constructive-io/llm-env when the module
|
|
10
|
+
* is not provisioned. Discovery and billing are handled by the shared loaders
|
|
11
|
+
* in express-context — no custom caching here.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { createContextMiddleware } from '@constructive-io/express-context';
|
|
16
|
+
* import { createAgenticRouter } from './agentic';
|
|
17
|
+
*
|
|
18
|
+
* app.use(createContextMiddleware());
|
|
19
|
+
* app.use(createAgenticRouter());
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export { createAgenticRouter } from './router';
|
|
23
|
+
export { getEnvOptions as getLlmEnvOptions, getEnvVars as getLlmEnvVars, llmDefaults } from '@constructive-io/llm-env';
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router — Express router for the agentic-server
|
|
3
|
+
*
|
|
4
|
+
* Provides REST endpoints for AI agent conversations:
|
|
5
|
+
*
|
|
6
|
+
* POST /v1/threads → create thread
|
|
7
|
+
* POST /v1/threads/:thread_id/messages → send message + stream response
|
|
8
|
+
* POST /v1/orgs/:entity_id/threads → create thread (entity-scoped)
|
|
9
|
+
* POST /v1/orgs/:entity_id/threads/:thread_id/messages → send message (entity-scoped)
|
|
10
|
+
* POST /v1/embed → generate embedding
|
|
11
|
+
*
|
|
12
|
+
* All routes require `req.constructive` (from @constructive-io/express-context).
|
|
13
|
+
* Billing (check_quota + record_usage) and inference logging are automatic
|
|
14
|
+
* when the billing/inference_log modules are provisioned.
|
|
15
|
+
*
|
|
16
|
+
* LLM provider config is resolved per-database via `ctx.useLlm()` from the
|
|
17
|
+
* llm_module table, falling back to env vars (EMBEDDER_*, CHAT_*) when the
|
|
18
|
+
* module is not provisioned.
|
|
19
|
+
*/
|
|
20
|
+
import { OllamaAdapter } from '@agentic-kit/ollama';
|
|
21
|
+
import { getEnvOptions as getLlmEnvOptions } from '@constructive-io/llm-env';
|
|
22
|
+
import { Logger } from '@pgpmjs/logger';
|
|
23
|
+
import express, { Router } from 'express';
|
|
24
|
+
const log = new Logger('agentic-server');
|
|
25
|
+
function resolveChatAdapter(llm) {
|
|
26
|
+
const provider = llm?.chatProvider ?? getLlmEnvOptions().chat.provider;
|
|
27
|
+
const model = llm?.chatModel ?? getLlmEnvOptions().chat.model;
|
|
28
|
+
const baseUrl = llm?.chatBaseUrl ?? getLlmEnvOptions().chat.baseUrl;
|
|
29
|
+
if (provider === 'ollama') {
|
|
30
|
+
return { adapter: new OllamaAdapter(baseUrl), model, baseUrl, provider };
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
function resolveEmbeddingAdapter(llm) {
|
|
35
|
+
const provider = llm?.embeddingProvider ?? getLlmEnvOptions().embedding.provider;
|
|
36
|
+
const model = llm?.embeddingModel ?? getLlmEnvOptions().embedding.model;
|
|
37
|
+
const baseUrl = llm?.embeddingBaseUrl ?? getLlmEnvOptions().embedding.baseUrl;
|
|
38
|
+
if (provider === 'ollama') {
|
|
39
|
+
return { adapter: new OllamaAdapter(baseUrl), model, provider };
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
// ─── Route Handlers ─────────────────────────────────────────────────────────
|
|
44
|
+
async function handleCreateThread(req, res, entityId) {
|
|
45
|
+
const ctx = req.constructive;
|
|
46
|
+
if (!ctx?.userId) {
|
|
47
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const agentChat = await ctx.useModule('agentChat');
|
|
51
|
+
if (!agentChat?.threadTableName) {
|
|
52
|
+
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const body = req.body || {};
|
|
56
|
+
const { schemaName, threadTableName } = agentChat;
|
|
57
|
+
const result = await ctx.withPgClient(async (client) => {
|
|
58
|
+
const { rows } = await client.query(`INSERT INTO "${schemaName}"."${threadTableName}"
|
|
59
|
+
(entity_id, owner_id, mode, model, system_prompt, title)
|
|
60
|
+
VALUES ($1, $2, $3, $4, $5, $6)
|
|
61
|
+
RETURNING id, mode, model, system_prompt, status, created_at`, [
|
|
62
|
+
entityId,
|
|
63
|
+
ctx.userId,
|
|
64
|
+
body.mode ?? 'ask',
|
|
65
|
+
body.model ?? null,
|
|
66
|
+
body.system_prompt ?? null,
|
|
67
|
+
body.title ?? null
|
|
68
|
+
]);
|
|
69
|
+
return rows[0];
|
|
70
|
+
});
|
|
71
|
+
res.status(201).json({
|
|
72
|
+
id: result.id,
|
|
73
|
+
mode: result.mode,
|
|
74
|
+
model: result.model,
|
|
75
|
+
system_prompt: result.system_prompt,
|
|
76
|
+
status: result.status,
|
|
77
|
+
created_at: result.created_at
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
async function handleSendMessage(req, res, entityId) {
|
|
81
|
+
const ctx = req.constructive;
|
|
82
|
+
if (!ctx?.userId) {
|
|
83
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const agentChat = await ctx.useModule('agentChat');
|
|
87
|
+
if (!agentChat?.threadTableName || !agentChat?.messageTableName) {
|
|
88
|
+
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const body = req.body || {};
|
|
92
|
+
if (!body.messages?.length) {
|
|
93
|
+
res.status(400).json({ error: 'messages[] is required and must not be empty' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const { schemaName, threadTableName, messageTableName } = agentChat;
|
|
97
|
+
const threadId = req.params.thread_id;
|
|
98
|
+
const userId = ctx.userId;
|
|
99
|
+
// Verify thread exists (RLS enforced)
|
|
100
|
+
const threadRow = await ctx.withPgClient(async (client) => {
|
|
101
|
+
const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
|
|
102
|
+
FROM "${schemaName}"."${threadTableName}"
|
|
103
|
+
WHERE id = $1`, [threadId]);
|
|
104
|
+
return rows[0];
|
|
105
|
+
});
|
|
106
|
+
if (!threadRow) {
|
|
107
|
+
res.status(404).json({ error: 'Thread not found' });
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// Resolve shared billing client and LLM config (lazy, cached per request)
|
|
111
|
+
const [billing, llm] = await Promise.all([ctx.useBilling(), ctx.useLlm()]);
|
|
112
|
+
const chatAdapter = resolveChatAdapter(llm);
|
|
113
|
+
if (!chatAdapter) {
|
|
114
|
+
res.status(503).json({ error: 'No LLM provider configured' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const model = body.model ?? threadRow.model ?? chatAdapter.model;
|
|
118
|
+
const meterSlug = model;
|
|
119
|
+
// Quota check
|
|
120
|
+
if (billing) {
|
|
121
|
+
const allowed = await billing.checkQuota(meterSlug);
|
|
122
|
+
if (!allowed) {
|
|
123
|
+
res.status(429).json({
|
|
124
|
+
error: 'Token quota exceeded',
|
|
125
|
+
meter: meterSlug,
|
|
126
|
+
entity_id: entityId
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Persist user messages
|
|
132
|
+
await ctx.withPgClient(async (client) => {
|
|
133
|
+
for (const msg of body.messages) {
|
|
134
|
+
if (msg.role === 'user') {
|
|
135
|
+
await client.query(`INSERT INTO "${schemaName}"."${messageTableName}"
|
|
136
|
+
(thread_id, owner_id, entity_id, author_role, parts)
|
|
137
|
+
VALUES ($1, $2, (SELECT entity_id FROM "${schemaName}"."${threadTableName}" WHERE id = $1), $3, $4)`, [threadId, userId, 'user', JSON.stringify([{ type: 'text', text: msg.content }])]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
// Load full thread history
|
|
142
|
+
const history = await ctx.withPgClient(async (client) => {
|
|
143
|
+
const { rows } = await client.query(`SELECT author_role, parts, created_at
|
|
144
|
+
FROM "${schemaName}"."${messageTableName}"
|
|
145
|
+
WHERE thread_id = $1
|
|
146
|
+
ORDER BY created_at ASC`, [threadId]);
|
|
147
|
+
return rows;
|
|
148
|
+
});
|
|
149
|
+
const llmMessages = [];
|
|
150
|
+
if (threadRow.system_prompt) {
|
|
151
|
+
llmMessages.push({ role: 'system', content: threadRow.system_prompt });
|
|
152
|
+
}
|
|
153
|
+
for (const row of history) {
|
|
154
|
+
const parts = Array.isArray(row.parts) ? row.parts : [];
|
|
155
|
+
const textContent = parts
|
|
156
|
+
.filter((p) => p.type === 'text')
|
|
157
|
+
.map((p) => p.text)
|
|
158
|
+
.join('');
|
|
159
|
+
if (textContent) {
|
|
160
|
+
llmMessages.push({
|
|
161
|
+
role: row.author_role === 'user' ? 'user' : 'assistant',
|
|
162
|
+
content: textContent
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const startTime = Date.now();
|
|
167
|
+
const shouldStream = body.stream !== false;
|
|
168
|
+
if (shouldStream) {
|
|
169
|
+
await handleStreamingResponse(req, res, {
|
|
170
|
+
ctx, chatAdapter, model, llmMessages, body,
|
|
171
|
+
entityId, userId, threadId,
|
|
172
|
+
schemaName, threadTableName, messageTableName,
|
|
173
|
+
billing, startTime, meterSlug
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
await handleBatchResponse(req, res, {
|
|
178
|
+
ctx, chatAdapter, model, llmMessages, body,
|
|
179
|
+
entityId, userId, threadId,
|
|
180
|
+
schemaName, threadTableName, messageTableName,
|
|
181
|
+
billing, startTime, meterSlug
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function handleStreamingResponse(_req, res, mc) {
|
|
186
|
+
const { ctx, chatAdapter, model, llmMessages, body, entityId, userId, threadId, schemaName, threadTableName, messageTableName, billing, startTime, meterSlug } = mc;
|
|
187
|
+
res.writeHead(200, {
|
|
188
|
+
'Content-Type': 'text/event-stream',
|
|
189
|
+
'Cache-Control': 'no-cache',
|
|
190
|
+
Connection: 'keep-alive',
|
|
191
|
+
'X-Accel-Buffering': 'no'
|
|
192
|
+
});
|
|
193
|
+
const messageId = `msg_${Date.now()}`;
|
|
194
|
+
try {
|
|
195
|
+
const systemMsg = llmMessages.find(m => m.role === 'system');
|
|
196
|
+
const nonSystem = llmMessages.filter(m => m.role !== 'system');
|
|
197
|
+
const modelDesc = chatAdapter.adapter.createModel(model, { maxOutputTokens: undefined });
|
|
198
|
+
const context = {
|
|
199
|
+
systemPrompt: systemMsg?.content,
|
|
200
|
+
messages: nonSystem.map((m) => ({
|
|
201
|
+
role: m.role,
|
|
202
|
+
content: m.content,
|
|
203
|
+
timestamp: Date.now()
|
|
204
|
+
}))
|
|
205
|
+
};
|
|
206
|
+
const stream = chatAdapter.adapter.stream(modelDesc, context, {
|
|
207
|
+
temperature: body.temperature
|
|
208
|
+
});
|
|
209
|
+
let streamedContent = '';
|
|
210
|
+
for await (const event of stream) {
|
|
211
|
+
if (event.type === 'text_delta') {
|
|
212
|
+
streamedContent += event.delta;
|
|
213
|
+
const sseEvent = {
|
|
214
|
+
id: messageId,
|
|
215
|
+
choices: [{
|
|
216
|
+
index: 0,
|
|
217
|
+
delta: { content: event.delta, role: 'assistant' },
|
|
218
|
+
finish_reason: null
|
|
219
|
+
}],
|
|
220
|
+
model
|
|
221
|
+
};
|
|
222
|
+
res.write(`data: ${JSON.stringify(sseEvent)}\n\n`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const result = await stream.result();
|
|
226
|
+
const content = streamedContent;
|
|
227
|
+
const latencyMs = Date.now() - startTime;
|
|
228
|
+
const usage = {
|
|
229
|
+
input: result.usage.input,
|
|
230
|
+
output: result.usage.output,
|
|
231
|
+
reasoning: result.usage.reasoning,
|
|
232
|
+
cacheRead: result.usage.cacheRead,
|
|
233
|
+
cacheWrite: result.usage.cacheWrite,
|
|
234
|
+
totalTokens: result.usage.totalTokens
|
|
235
|
+
};
|
|
236
|
+
res.write('data: [DONE]\n\n');
|
|
237
|
+
res.end();
|
|
238
|
+
// Persist assistant message (fire-and-forget)
|
|
239
|
+
if (content) {
|
|
240
|
+
ctx.withPgClient(async (client) => {
|
|
241
|
+
await client.query(`INSERT INTO "${schemaName}"."${messageTableName}"
|
|
242
|
+
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
243
|
+
VALUES ($1, $2, (SELECT entity_id FROM "${schemaName}"."${threadTableName}" WHERE id = $1), $3, $4, $5)`, [threadId, userId, 'assistant', JSON.stringify([{ type: 'text', text: content }]), model]);
|
|
244
|
+
}).catch((err) => log.error('Failed to persist assistant message:', err));
|
|
245
|
+
}
|
|
246
|
+
// Record billing usage + inference log (fire-and-forget)
|
|
247
|
+
if (billing && usage.totalTokens > 0) {
|
|
248
|
+
billing.recordUsage(meterSlug, usage.totalTokens, {
|
|
249
|
+
input_tokens: usage.input,
|
|
250
|
+
output_tokens: usage.output,
|
|
251
|
+
cache_read_tokens: usage.cacheRead,
|
|
252
|
+
cache_write_tokens: usage.cacheWrite,
|
|
253
|
+
model,
|
|
254
|
+
latency_ms: latencyMs,
|
|
255
|
+
stream: true
|
|
256
|
+
}).catch(() => { });
|
|
257
|
+
billing.logInference({
|
|
258
|
+
entityId, actorId: userId, model, provider: chatAdapter.provider,
|
|
259
|
+
service: 'llm', operation: 'chat',
|
|
260
|
+
inputTokens: usage.input, outputTokens: usage.output,
|
|
261
|
+
totalTokens: usage.totalTokens, latencyMs, status: 'ok'
|
|
262
|
+
}).catch(() => { });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (streamErr) {
|
|
266
|
+
log.error('Streaming error:', streamErr);
|
|
267
|
+
const errorEvent = { error: { message: streamErr.message, type: 'stream_error' } };
|
|
268
|
+
res.write(`data: ${JSON.stringify(errorEvent)}\n\n`);
|
|
269
|
+
res.write('data: [DONE]\n\n');
|
|
270
|
+
res.end();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function handleBatchResponse(_req, res, mc) {
|
|
274
|
+
const { ctx, chatAdapter, model, llmMessages, body, entityId, userId, threadId, schemaName, threadTableName, messageTableName, billing, startTime, meterSlug } = mc;
|
|
275
|
+
const systemMsg = llmMessages.find(m => m.role === 'system');
|
|
276
|
+
const nonSystem = llmMessages.filter(m => m.role !== 'system');
|
|
277
|
+
const modelDesc = chatAdapter.adapter.createModel(model, { maxOutputTokens: undefined });
|
|
278
|
+
const context = {
|
|
279
|
+
systemPrompt: systemMsg?.content,
|
|
280
|
+
messages: nonSystem.map((m) => ({
|
|
281
|
+
role: m.role,
|
|
282
|
+
content: m.content,
|
|
283
|
+
timestamp: Date.now()
|
|
284
|
+
}))
|
|
285
|
+
};
|
|
286
|
+
const stream = chatAdapter.adapter.stream(modelDesc, context, {
|
|
287
|
+
temperature: body.temperature
|
|
288
|
+
});
|
|
289
|
+
const result = await stream.result();
|
|
290
|
+
const content = result.content
|
|
291
|
+
.filter((block) => block.type === 'text')
|
|
292
|
+
.map((block) => block.text)
|
|
293
|
+
.join('');
|
|
294
|
+
const latencyMs = Date.now() - startTime;
|
|
295
|
+
const usage = {
|
|
296
|
+
input: result.usage.input,
|
|
297
|
+
output: result.usage.output,
|
|
298
|
+
reasoning: result.usage.reasoning,
|
|
299
|
+
cacheRead: result.usage.cacheRead,
|
|
300
|
+
cacheWrite: result.usage.cacheWrite,
|
|
301
|
+
totalTokens: result.usage.totalTokens
|
|
302
|
+
};
|
|
303
|
+
// Persist assistant message
|
|
304
|
+
await ctx.withPgClient(async (client) => {
|
|
305
|
+
await client.query(`INSERT INTO "${schemaName}"."${messageTableName}"
|
|
306
|
+
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
307
|
+
VALUES ($1, $2, (SELECT entity_id FROM "${schemaName}"."${threadTableName}" WHERE id = $1), $3, $4, $5)`, [threadId, userId, 'assistant', JSON.stringify([{ type: 'text', text: content }]), model]);
|
|
308
|
+
});
|
|
309
|
+
// Record billing + inference log (fire-and-forget)
|
|
310
|
+
if (billing && usage.totalTokens > 0) {
|
|
311
|
+
billing.recordUsage(meterSlug, usage.totalTokens, {
|
|
312
|
+
input_tokens: usage.input,
|
|
313
|
+
output_tokens: usage.output,
|
|
314
|
+
cache_read_tokens: usage.cacheRead,
|
|
315
|
+
cache_write_tokens: usage.cacheWrite,
|
|
316
|
+
model,
|
|
317
|
+
latency_ms: latencyMs,
|
|
318
|
+
stream: false
|
|
319
|
+
}).catch(() => { });
|
|
320
|
+
billing.logInference({
|
|
321
|
+
entityId, actorId: userId, model, provider: chatAdapter.provider,
|
|
322
|
+
service: 'llm', operation: 'chat',
|
|
323
|
+
inputTokens: usage.input, outputTokens: usage.output,
|
|
324
|
+
totalTokens: usage.totalTokens, latencyMs, status: 'ok'
|
|
325
|
+
}).catch(() => { });
|
|
326
|
+
}
|
|
327
|
+
res.json({
|
|
328
|
+
id: `msg_${Date.now()}`,
|
|
329
|
+
choices: [{
|
|
330
|
+
index: 0,
|
|
331
|
+
message: { role: 'assistant', content },
|
|
332
|
+
finish_reason: 'stop'
|
|
333
|
+
}],
|
|
334
|
+
model,
|
|
335
|
+
usage: {
|
|
336
|
+
prompt_tokens: usage.input,
|
|
337
|
+
completion_tokens: usage.output,
|
|
338
|
+
total_tokens: usage.totalTokens
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
// ─── Embedding Handler ──────────────────────────────────────────────────────
|
|
343
|
+
async function handleEmbed(req, res) {
|
|
344
|
+
const ctx = req.constructive;
|
|
345
|
+
if (!ctx?.userId) {
|
|
346
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const body = req.body || {};
|
|
350
|
+
if (!body.input) {
|
|
351
|
+
res.status(400).json({ error: 'input is required' });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const llm = await ctx.useLlm();
|
|
355
|
+
const embedAdapter = resolveEmbeddingAdapter(llm);
|
|
356
|
+
if (!embedAdapter) {
|
|
357
|
+
res.status(503).json({ error: 'No embedding provider configured' });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const model = body.model ?? embedAdapter.model;
|
|
361
|
+
const inputs = Array.isArray(body.input) ? body.input : [body.input];
|
|
362
|
+
// Resolve shared billing client
|
|
363
|
+
const billing = await ctx.useBilling();
|
|
364
|
+
// Quota check
|
|
365
|
+
if (billing) {
|
|
366
|
+
const allowed = await billing.checkQuota(model);
|
|
367
|
+
if (!allowed) {
|
|
368
|
+
res.status(429).json({ error: 'Embedding quota exceeded', meter: model });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
const startTime = Date.now();
|
|
373
|
+
try {
|
|
374
|
+
const results = await Promise.all(inputs.map((text) => embedAdapter.adapter.embed(text, model)));
|
|
375
|
+
const latencyMs = Date.now() - startTime;
|
|
376
|
+
const totalTokens = results.reduce((sum, r) => sum + r.promptTokens, 0);
|
|
377
|
+
// Record usage + inference log (fire-and-forget)
|
|
378
|
+
if (billing && totalTokens > 0) {
|
|
379
|
+
billing.recordUsage(model, totalTokens, {
|
|
380
|
+
input_tokens: totalTokens,
|
|
381
|
+
model,
|
|
382
|
+
latency_ms: latencyMs,
|
|
383
|
+
batch_size: inputs.length
|
|
384
|
+
}).catch(() => { });
|
|
385
|
+
billing.logInference({
|
|
386
|
+
entityId: ctx.userId,
|
|
387
|
+
actorId: ctx.userId,
|
|
388
|
+
model,
|
|
389
|
+
provider: embedAdapter.provider,
|
|
390
|
+
service: 'embedding',
|
|
391
|
+
operation: 'embed',
|
|
392
|
+
inputTokens: totalTokens,
|
|
393
|
+
outputTokens: 0,
|
|
394
|
+
totalTokens,
|
|
395
|
+
latencyMs,
|
|
396
|
+
status: 'ok'
|
|
397
|
+
}).catch(() => { });
|
|
398
|
+
}
|
|
399
|
+
res.json({
|
|
400
|
+
object: 'list',
|
|
401
|
+
data: results.map((r, i) => ({
|
|
402
|
+
object: 'embedding',
|
|
403
|
+
index: i,
|
|
404
|
+
embedding: r.embedding
|
|
405
|
+
})),
|
|
406
|
+
model,
|
|
407
|
+
usage: {
|
|
408
|
+
prompt_tokens: totalTokens,
|
|
409
|
+
total_tokens: totalTokens
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
catch (err) {
|
|
414
|
+
log.error('Embedding error:', err);
|
|
415
|
+
res.status(500).json({ error: err.message ?? 'Embedding failed' });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// ─── Router Factory ─────────────────────────────────────────────────────────
|
|
419
|
+
export function createAgenticRouter() {
|
|
420
|
+
const router = Router();
|
|
421
|
+
router.use(express.json());
|
|
422
|
+
// Entity-scoped routes
|
|
423
|
+
router.post('/v1/orgs/:entity_id/threads', async (req, res) => {
|
|
424
|
+
try {
|
|
425
|
+
await handleCreateThread(req, res, req.params.entity_id);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
log.error('Error creating thread:', err);
|
|
429
|
+
if (!res.headersSent)
|
|
430
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
router.post('/v1/orgs/:entity_id/threads/:thread_id/messages', async (req, res) => {
|
|
434
|
+
try {
|
|
435
|
+
await handleSendMessage(req, res, req.params.entity_id);
|
|
436
|
+
}
|
|
437
|
+
catch (err) {
|
|
438
|
+
log.error('Error in messages endpoint:', err);
|
|
439
|
+
if (!res.headersSent)
|
|
440
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
// Global routes (entity_id = user_id from JWT)
|
|
444
|
+
router.post('/v1/threads', async (req, res) => {
|
|
445
|
+
try {
|
|
446
|
+
const userId = req.constructive?.userId;
|
|
447
|
+
if (!userId) {
|
|
448
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
await handleCreateThread(req, res, userId);
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
log.error('Error creating thread:', err);
|
|
455
|
+
if (!res.headersSent)
|
|
456
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
router.post('/v1/threads/:thread_id/messages', async (req, res) => {
|
|
460
|
+
try {
|
|
461
|
+
const userId = req.constructive?.userId;
|
|
462
|
+
if (!userId) {
|
|
463
|
+
res.status(401).json({ error: 'Authentication required' });
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
await handleSendMessage(req, res, userId);
|
|
467
|
+
}
|
|
468
|
+
catch (err) {
|
|
469
|
+
log.error('Error in messages endpoint:', err);
|
|
470
|
+
if (!res.headersSent)
|
|
471
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
// Embedding endpoint
|
|
475
|
+
router.post('/v1/embed', async (req, res) => {
|
|
476
|
+
try {
|
|
477
|
+
await handleEmbed(req, res);
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
log.error('Error in embed endpoint:', err);
|
|
481
|
+
if (!res.headersSent)
|
|
482
|
+
res.status(500).json({ error: 'Internal server error' });
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
return router;
|
|
486
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import './types'; // for Request type
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
-
import { classify, parse } from '@constructive-io/errors';
|
|
3
|
+
import { classify, errors, parse } from '@constructive-io/errors';
|
|
4
4
|
import { getNodeEnv } from '@pgpmjs/env';
|
|
5
5
|
import { Logger } from '@pgpmjs/logger';
|
|
6
6
|
import { createGraphileInstance, graphileCache } from 'graphile-cache';
|
|
@@ -10,9 +10,11 @@ import { getPgPool } from 'pg-cache';
|
|
|
10
10
|
import { getPgEnvOptions } from 'pg-env';
|
|
11
11
|
import { isGraphqlObservabilityEnabled } from '../diagnostics/observability';
|
|
12
12
|
import { HandlerCreationError } from '../errors/api-errors';
|
|
13
|
+
import { respondWithGraphQLError } from '../errors/graphql-response';
|
|
13
14
|
import { AuthCookiePlugin } from '../plugins/auth-cookie-plugin';
|
|
14
15
|
import { observeGraphileBuild } from './observability/graphile-build-stats';
|
|
15
16
|
const maskErrorLog = new Logger('graphile:maskError');
|
|
17
|
+
const isDev = () => getNodeEnv() === 'development';
|
|
16
18
|
/**
|
|
17
19
|
* GraphQL framework protocol codes. These originate in the GraphQL/grafast
|
|
18
20
|
* transport layer (not in constructive-db), so they are not Constructive domain
|
|
@@ -276,12 +278,14 @@ export const graphile = (opts) => {
|
|
|
276
278
|
const api = req.api;
|
|
277
279
|
if (!api) {
|
|
278
280
|
log.error(`${label} Missing API info`);
|
|
279
|
-
|
|
281
|
+
respondWithGraphQLError(res, errors.INTERNAL_FAILURE({ details: 'Missing API info' }));
|
|
282
|
+
return;
|
|
280
283
|
}
|
|
281
284
|
const key = req.svc_key;
|
|
282
285
|
if (!key) {
|
|
283
286
|
log.error(`${label} Missing service cache key`);
|
|
284
|
-
|
|
287
|
+
respondWithGraphQLError(res, errors.INTERNAL_FAILURE({ details: 'Missing service cache key' }));
|
|
288
|
+
return;
|
|
285
289
|
}
|
|
286
290
|
const { dbname, anonRole, roleName, schema } = api;
|
|
287
291
|
const schemaLabel = schema?.join(',') || 'unknown';
|
|
@@ -367,9 +371,10 @@ export const graphile = (opts) => {
|
|
|
367
371
|
catch (e) {
|
|
368
372
|
log.error(`${label} PostGraphile middleware error`, e);
|
|
369
373
|
if (!res.headersSent) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
});
|
|
374
|
+
respondWithGraphQLError(res, errors.INTERNAL_FAILURE({
|
|
375
|
+
details: isDev() ? e?.message ?? String(e) : 'An unexpected error occurred'
|
|
376
|
+
}));
|
|
377
|
+
return;
|
|
373
378
|
}
|
|
374
379
|
next(e);
|
|
375
380
|
}
|
package/esm/server.js
CHANGED
|
@@ -4,13 +4,13 @@ import { getEnvOptions } from '@constructive-io/graphql-env';
|
|
|
4
4
|
import { middleware as parseDomains } from '@constructive-io/url-domains';
|
|
5
5
|
import { Logger } from '@pgpmjs/logger';
|
|
6
6
|
import { healthz, poweredBy, svcCache, trustProxy } from '@pgpmjs/server-utils';
|
|
7
|
-
import { createAgenticRouter } from 'agentic-server';
|
|
8
7
|
import cookieParser from 'cookie-parser';
|
|
9
8
|
import express from 'express';
|
|
10
9
|
import { closeAllCaches, graphileCache } from 'graphile-cache';
|
|
11
10
|
import graphqlUpload from 'graphql-upload';
|
|
12
11
|
import { getPgPool } from 'pg-cache';
|
|
13
12
|
import requestIp from 'request-ip';
|
|
13
|
+
import { createAgenticRouter } from './agentic';
|
|
14
14
|
import { closeDebugDatabasePools } from './diagnostics/debug-db-snapshot';
|
|
15
15
|
import { startDebugSampler } from './diagnostics/debug-sampler';
|
|
16
16
|
import { isDevelopmentObservabilityMode, isGraphqlObservabilityEnabled, isGraphqlObservabilityRequested, isLoopbackHost } from './diagnostics/observability';
|
package/middleware/graphile.js
CHANGED
|
@@ -19,9 +19,11 @@ const pg_cache_1 = require("pg-cache");
|
|
|
19
19
|
const pg_env_1 = require("pg-env");
|
|
20
20
|
const observability_1 = require("../diagnostics/observability");
|
|
21
21
|
const api_errors_1 = require("../errors/api-errors");
|
|
22
|
+
const graphql_response_1 = require("../errors/graphql-response");
|
|
22
23
|
const auth_cookie_plugin_1 = require("../plugins/auth-cookie-plugin");
|
|
23
24
|
const graphile_build_stats_1 = require("./observability/graphile-build-stats");
|
|
24
25
|
const maskErrorLog = new logger_1.Logger('graphile:maskError');
|
|
26
|
+
const isDev = () => (0, env_1.getNodeEnv)() === 'development';
|
|
25
27
|
/**
|
|
26
28
|
* GraphQL framework protocol codes. These originate in the GraphQL/grafast
|
|
27
29
|
* transport layer (not in constructive-db), so they are not Constructive domain
|
|
@@ -285,12 +287,14 @@ const graphile = (opts) => {
|
|
|
285
287
|
const api = req.api;
|
|
286
288
|
if (!api) {
|
|
287
289
|
log.error(`${label} Missing API info`);
|
|
288
|
-
|
|
290
|
+
(0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.INTERNAL_FAILURE({ details: 'Missing API info' }));
|
|
291
|
+
return;
|
|
289
292
|
}
|
|
290
293
|
const key = req.svc_key;
|
|
291
294
|
if (!key) {
|
|
292
295
|
log.error(`${label} Missing service cache key`);
|
|
293
|
-
|
|
296
|
+
(0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.INTERNAL_FAILURE({ details: 'Missing service cache key' }));
|
|
297
|
+
return;
|
|
294
298
|
}
|
|
295
299
|
const { dbname, anonRole, roleName, schema } = api;
|
|
296
300
|
const schemaLabel = schema?.join(',') || 'unknown';
|
|
@@ -376,9 +380,10 @@ const graphile = (opts) => {
|
|
|
376
380
|
catch (e) {
|
|
377
381
|
log.error(`${label} PostGraphile middleware error`, e);
|
|
378
382
|
if (!res.headersSent) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
});
|
|
383
|
+
(0, graphql_response_1.respondWithGraphQLError)(res, errors_1.errors.INTERNAL_FAILURE({
|
|
384
|
+
details: isDev() ? e?.message ?? String(e) : 'An unexpected error occurred'
|
|
385
|
+
}));
|
|
386
|
+
return;
|
|
382
387
|
}
|
|
383
388
|
next(e);
|
|
384
389
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@constructive-io/graphql-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.15.0",
|
|
4
4
|
"author": "Constructive <developers@constructive.io>",
|
|
5
5
|
"description": "Constructive GraphQL Server",
|
|
6
6
|
"main": "index.js",
|
|
@@ -41,12 +41,14 @@
|
|
|
41
41
|
"backend"
|
|
42
42
|
],
|
|
43
43
|
"dependencies": {
|
|
44
|
+
"@agentic-kit/ollama": "2.12.2",
|
|
44
45
|
"@constructive-io/csrf": "^0.26.2",
|
|
45
46
|
"@constructive-io/errors": "^0.8.2",
|
|
46
47
|
"@constructive-io/express-context": "^0.23.2",
|
|
47
48
|
"@constructive-io/graphql-env": "^3.28.2",
|
|
48
49
|
"@constructive-io/graphql-types": "^3.27.2",
|
|
49
|
-
"@constructive-io/
|
|
50
|
+
"@constructive-io/llm-env": "^0.12.2",
|
|
51
|
+
"@constructive-io/query-builder": "^3.9.4",
|
|
50
52
|
"@constructive-io/s3-utils": "^2.29.2",
|
|
51
53
|
"@constructive-io/url-domains": "^2.28.2",
|
|
52
54
|
"@graphile-contrib/pg-many-to-many": "2.0.0-rc.2",
|
|
@@ -54,7 +56,6 @@
|
|
|
54
56
|
"@pgpmjs/logger": "^2.23.2",
|
|
55
57
|
"@pgpmjs/server-utils": "^3.24.2",
|
|
56
58
|
"@pgpmjs/types": "^2.49.2",
|
|
57
|
-
"agentic-server": "0.21.2",
|
|
58
59
|
"cors": "^2.8.6",
|
|
59
60
|
"deepmerge": "^4.3.1",
|
|
60
61
|
"express": "^5.2.1",
|
|
@@ -65,8 +66,8 @@
|
|
|
65
66
|
"graphile-build-pg": "5.0.2",
|
|
66
67
|
"graphile-cache": "^4.9.2",
|
|
67
68
|
"graphile-config": "1.0.1",
|
|
68
|
-
"graphile-function-bindings": "^1.10.
|
|
69
|
-
"graphile-settings": "^6.11.
|
|
69
|
+
"graphile-function-bindings": "^1.10.7",
|
|
70
|
+
"graphile-settings": "^6.11.7",
|
|
70
71
|
"graphile-utils": "5.0.1",
|
|
71
72
|
"graphql": "16.13.0",
|
|
72
73
|
"graphql-upload": "^13.0.0",
|
|
@@ -87,11 +88,13 @@
|
|
|
87
88
|
"@types/graphql-upload": "^8.0.12",
|
|
88
89
|
"@types/pg": "^8.20.0",
|
|
89
90
|
"@types/request-ip": "^0.0.41",
|
|
91
|
+
"@types/supertest": "^7.2.0",
|
|
90
92
|
"cookie-parser": "^1.4.7",
|
|
91
|
-
"graphile-test": "5.10.
|
|
93
|
+
"graphile-test": "5.10.7",
|
|
92
94
|
"makage": "^0.3.0",
|
|
93
95
|
"nodemon": "^3.1.14",
|
|
96
|
+
"supertest": "^7.2.2",
|
|
94
97
|
"ts-node": "^10.9.2"
|
|
95
98
|
},
|
|
96
|
-
"gitHead": "
|
|
99
|
+
"gitHead": "6d3e25dc5c9f93421dba49ddedf93239f87213a8"
|
|
97
100
|
}
|
package/server.js
CHANGED
|
@@ -10,13 +10,13 @@ const graphql_env_1 = require("@constructive-io/graphql-env");
|
|
|
10
10
|
const url_domains_1 = require("@constructive-io/url-domains");
|
|
11
11
|
const logger_1 = require("@pgpmjs/logger");
|
|
12
12
|
const server_utils_1 = require("@pgpmjs/server-utils");
|
|
13
|
-
const agentic_server_1 = require("agentic-server");
|
|
14
13
|
const cookie_parser_1 = __importDefault(require("cookie-parser"));
|
|
15
14
|
const express_1 = __importDefault(require("express"));
|
|
16
15
|
const graphile_cache_1 = require("graphile-cache");
|
|
17
16
|
const graphql_upload_1 = __importDefault(require("graphql-upload"));
|
|
18
17
|
const pg_cache_1 = require("pg-cache");
|
|
19
18
|
const request_ip_1 = __importDefault(require("request-ip"));
|
|
19
|
+
const agentic_1 = require("./agentic");
|
|
20
20
|
const debug_db_snapshot_1 = require("./diagnostics/debug-db-snapshot");
|
|
21
21
|
const debug_sampler_1 = require("./diagnostics/debug-sampler");
|
|
22
22
|
const observability_1 = require("./diagnostics/observability");
|
|
@@ -180,7 +180,7 @@ class Server {
|
|
|
180
180
|
app.use('/graphql', csrfProtect); // Enforce CSRF on GraphQL mutations
|
|
181
181
|
// LLM Agent REST API — mounted before graphile so SSE streaming
|
|
182
182
|
// routes are handled without going through PostGraphile
|
|
183
|
-
app.use((0,
|
|
183
|
+
app.use((0, agentic_1.createAgenticRouter)());
|
|
184
184
|
// REST function invocation routes (POST /fn/:alias, GET /fn/invocations/:id)
|
|
185
185
|
app.use((0, fn_1.createFnRouter)());
|
|
186
186
|
app.use((0, graphile_1.graphile)(effectiveOpts));
|