@constructive-io/graphql-server 4.36.1 → 4.36.3
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/index.js +0 -1
- package/esm/server.js +2 -5
- package/index.d.ts +0 -1
- package/index.js +1 -3
- package/package.json +4 -9
- package/server.js +2 -5
- package/esm/middleware/llm-api.js +0 -539
- package/esm/middleware/upload.js +0 -341
- package/middleware/llm-api.d.ts +0 -27
- package/middleware/llm-api.js +0 -575
- package/middleware/upload.d.ts +0 -20
- package/middleware/upload.js +0 -348
package/middleware/llm-api.js
DELETED
|
@@ -1,575 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* LLM API Router
|
|
4
|
-
*
|
|
5
|
-
* Express router providing REST streaming endpoints for AI agent conversations.
|
|
6
|
-
* Uses the agent tables (agent_thread, agent_message) discovered from the
|
|
7
|
-
* agent_chat_module config table at runtime.
|
|
8
|
-
*
|
|
9
|
-
* Hybrid architecture:
|
|
10
|
-
* - GraphQL handles CRUD (threads, messages, tasks) via PostGraphile
|
|
11
|
-
* - REST handles SSE streaming for chat completions (what GraphQL can't do)
|
|
12
|
-
*
|
|
13
|
-
* Routes (entity-scoped):
|
|
14
|
-
* POST /v1/orgs/:entity_id/threads → create thread
|
|
15
|
-
* POST /v1/orgs/:entity_id/threads/:thread_id/messages → send message + stream response
|
|
16
|
-
*
|
|
17
|
-
* Routes (global — bills to actor_id from JWT):
|
|
18
|
-
* POST /v1/threads → create thread (entity_id = user_id)
|
|
19
|
-
* POST /v1/threads/:thread_id/messages → send message + stream response
|
|
20
|
-
*
|
|
21
|
-
* Auth: JWT from the auth middleware (req.token) → pg SET LOCAL context for RLS
|
|
22
|
-
* Metering: check_billing_quota → LLM call → record_usage with real token counts
|
|
23
|
-
*
|
|
24
|
-
* Context: Uses `req.constructive` from @constructive-io/express-context
|
|
25
|
-
* for tenant-scoped database access, pgSettings, and withPgClient.
|
|
26
|
-
*/
|
|
27
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
28
|
-
if (k2 === undefined) k2 = k;
|
|
29
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
30
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
31
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
32
|
-
}
|
|
33
|
-
Object.defineProperty(o, k2, desc);
|
|
34
|
-
}) : (function(o, m, k, k2) {
|
|
35
|
-
if (k2 === undefined) k2 = k;
|
|
36
|
-
o[k2] = m[k];
|
|
37
|
-
}));
|
|
38
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
39
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
40
|
-
}) : function(o, v) {
|
|
41
|
-
o["default"] = v;
|
|
42
|
-
});
|
|
43
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
44
|
-
var ownKeys = function(o) {
|
|
45
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
46
|
-
var ar = [];
|
|
47
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
48
|
-
return ar;
|
|
49
|
-
};
|
|
50
|
-
return ownKeys(o);
|
|
51
|
-
};
|
|
52
|
-
return function (mod) {
|
|
53
|
-
if (mod && mod.__esModule) return mod;
|
|
54
|
-
var result = {};
|
|
55
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
56
|
-
__setModuleDefault(result, mod);
|
|
57
|
-
return result;
|
|
58
|
-
};
|
|
59
|
-
})();
|
|
60
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
61
|
-
exports.createLlmApiRouter = createLlmApiRouter;
|
|
62
|
-
const express_1 = __importStar(require("express"));
|
|
63
|
-
const logger_1 = require("@pgpmjs/logger");
|
|
64
|
-
const ollama_1 = require("@agentic-kit/ollama");
|
|
65
|
-
const graphile_cache_1 = require("graphile-cache");
|
|
66
|
-
const graphile_llm_1 = require("graphile-llm");
|
|
67
|
-
const log = new logger_1.Logger('llm-api');
|
|
68
|
-
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
69
|
-
function resolveOllamaAdapter() {
|
|
70
|
-
const { chat } = (0, graphile_llm_1.getLlmEnvOptions)();
|
|
71
|
-
if (chat.provider === 'ollama') {
|
|
72
|
-
return {
|
|
73
|
-
adapter: new ollama_1.OllamaAdapter(chat.baseUrl),
|
|
74
|
-
model: chat.model,
|
|
75
|
-
baseUrl: chat.baseUrl,
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
// ─── Billing Helpers ────────────────────────────────────────────────────────
|
|
81
|
-
async function checkQuota(ctx, billing, entityId, meterSlug) {
|
|
82
|
-
try {
|
|
83
|
-
return await ctx.withPgClient(async (client) => {
|
|
84
|
-
const sql = `SELECT "${billing.privateSchema}"."${billing.checkBillingQuotaFunction}"($1, $2::uuid, $3) AS allowed`;
|
|
85
|
-
const result = await client.query(sql, [meterSlug, entityId, 1]);
|
|
86
|
-
return result.rows[0]?.allowed !== false;
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
catch (e) {
|
|
90
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
91
|
-
log.warn(`[llm-api] check_billing_quota failed (allowing): ${message}`);
|
|
92
|
-
return true;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
async function recordUsage(ctx, billing, entityId, meterSlug, amount, metadata) {
|
|
96
|
-
try {
|
|
97
|
-
await ctx.withPgClient(async (client) => {
|
|
98
|
-
const sql = `SELECT "${billing.privateSchema}"."${billing.recordUsageFunction}"($1, $2::uuid, $3, $4::jsonb)`;
|
|
99
|
-
await client.query(sql, [meterSlug, entityId, amount, JSON.stringify(metadata)]);
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
catch (e) {
|
|
103
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
104
|
-
log.warn(`[llm-api] record_usage failed (non-fatal): ${message}`);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
async function resolveBilling(ctx) {
|
|
108
|
-
if (!ctx.databaseId)
|
|
109
|
-
return null;
|
|
110
|
-
try {
|
|
111
|
-
let billing = null;
|
|
112
|
-
await ctx.withPgClient(async (client) => {
|
|
113
|
-
const entry = await (0, graphile_llm_1.getLlmBillingConfig)(client, ctx.databaseId);
|
|
114
|
-
billing = entry.billing;
|
|
115
|
-
});
|
|
116
|
-
return billing;
|
|
117
|
-
}
|
|
118
|
-
catch {
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
const INFERENCE_LOG_DISCOVERY_SQL = `
|
|
123
|
-
SELECT s.schema_name, ilm.inference_log_table_name
|
|
124
|
-
FROM metaschema_modules_public.inference_log_module ilm
|
|
125
|
-
JOIN metaschema_public.schema s ON s.id = ilm.schema_id
|
|
126
|
-
LIMIT 1
|
|
127
|
-
`;
|
|
128
|
-
const inferenceLogCache = new graphile_cache_1.ModuleConfigCache({
|
|
129
|
-
name: 'inference-log',
|
|
130
|
-
ttlMs: 60_000,
|
|
131
|
-
});
|
|
132
|
-
async function getInferenceLogInfo(pool, dbname) {
|
|
133
|
-
const cached = inferenceLogCache.get(dbname);
|
|
134
|
-
if (cached !== undefined)
|
|
135
|
-
return cached;
|
|
136
|
-
let info = null;
|
|
137
|
-
try {
|
|
138
|
-
const { rows } = await pool.query(INFERENCE_LOG_DISCOVERY_SQL);
|
|
139
|
-
if (rows.length > 0) {
|
|
140
|
-
info = {
|
|
141
|
-
schemaName: rows[0].schema_name,
|
|
142
|
-
tableName: rows[0].inference_log_table_name,
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
// Module not provisioned
|
|
148
|
-
}
|
|
149
|
-
inferenceLogCache.set(dbname, info);
|
|
150
|
-
return info;
|
|
151
|
-
}
|
|
152
|
-
async function logInference(ctx, logInfo, data) {
|
|
153
|
-
try {
|
|
154
|
-
await ctx.withPgClient(async (client) => {
|
|
155
|
-
await client.query(`INSERT INTO "${logInfo.schemaName}"."${logInfo.tableName}"
|
|
156
|
-
(entity_id, actor_id, model, provider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status)
|
|
157
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [
|
|
158
|
-
data.entityId,
|
|
159
|
-
data.actorId,
|
|
160
|
-
data.model,
|
|
161
|
-
data.provider,
|
|
162
|
-
data.service,
|
|
163
|
-
data.operation,
|
|
164
|
-
data.inputTokens,
|
|
165
|
-
data.outputTokens,
|
|
166
|
-
data.totalTokens,
|
|
167
|
-
data.latencyMs,
|
|
168
|
-
data.status,
|
|
169
|
-
]);
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
catch (e) {
|
|
173
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
174
|
-
log.warn(`[llm-api] inference log INSERT failed (non-fatal): ${message}`);
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
// ─── Route Handlers ─────────────────────────────────────────────────────────
|
|
178
|
-
async function handleCreateThread(req, res, entityId) {
|
|
179
|
-
const ctx = req.constructive;
|
|
180
|
-
if (!ctx?.userId) {
|
|
181
|
-
res.status(401).json({ error: 'Authentication required' });
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
if (!ctx.api.dbname) {
|
|
185
|
-
res.status(400).json({ error: 'Database not resolved' });
|
|
186
|
-
return;
|
|
187
|
-
}
|
|
188
|
-
const discovery = await (0, graphile_llm_1.getAgentDiscovery)(ctx.pool, ctx.api.dbname);
|
|
189
|
-
if (!discovery?.thread) {
|
|
190
|
-
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
const body = req.body || {};
|
|
194
|
-
const { thread } = discovery;
|
|
195
|
-
const result = await ctx.withPgClient(async (client) => {
|
|
196
|
-
const { rows } = await client.query(`INSERT INTO "${thread.schemaName}"."${thread.tableName}"
|
|
197
|
-
(entity_id, owner_id, mode, model, system_prompt, title)
|
|
198
|
-
VALUES ($1, $2, $3, $4, $5, $6)
|
|
199
|
-
RETURNING id, mode, model, system_prompt, status, created_at`, [
|
|
200
|
-
entityId,
|
|
201
|
-
ctx.userId,
|
|
202
|
-
body.mode ?? 'ask',
|
|
203
|
-
body.model ?? null,
|
|
204
|
-
body.system_prompt ?? null,
|
|
205
|
-
body.title ?? null,
|
|
206
|
-
]);
|
|
207
|
-
return rows[0];
|
|
208
|
-
});
|
|
209
|
-
res.status(201).json({
|
|
210
|
-
id: result.id,
|
|
211
|
-
mode: result.mode,
|
|
212
|
-
model: result.model,
|
|
213
|
-
system_prompt: result.system_prompt,
|
|
214
|
-
status: result.status,
|
|
215
|
-
created_at: result.created_at,
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
async function handleSendMessage(req, res, entityId) {
|
|
219
|
-
const ctx = req.constructive;
|
|
220
|
-
if (!ctx?.userId) {
|
|
221
|
-
res.status(401).json({ error: 'Authentication required' });
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
if (!ctx.api.dbname) {
|
|
225
|
-
res.status(400).json({ error: 'Database not resolved' });
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
const discovery = await (0, graphile_llm_1.getAgentDiscovery)(ctx.pool, ctx.api.dbname);
|
|
229
|
-
if (!discovery?.thread || !discovery?.message) {
|
|
230
|
-
res.status(404).json({ error: 'Agent module not provisioned for this database' });
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
const body = req.body || {};
|
|
234
|
-
if (!body.messages?.length) {
|
|
235
|
-
res.status(400).json({ error: 'messages[] is required and must not be empty' });
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
const { thread, message: msgTable } = discovery;
|
|
239
|
-
const threadId = req.params.thread_id;
|
|
240
|
-
const userId = ctx.userId;
|
|
241
|
-
// 1. Verify thread exists and user owns it (RLS enforced)
|
|
242
|
-
const threadRow = await ctx.withPgClient(async (client) => {
|
|
243
|
-
const { rows } = await client.query(`SELECT id, mode, model, system_prompt, status
|
|
244
|
-
FROM "${thread.schemaName}"."${thread.tableName}"
|
|
245
|
-
WHERE id = $1`, [threadId]);
|
|
246
|
-
return rows[0];
|
|
247
|
-
});
|
|
248
|
-
if (!threadRow) {
|
|
249
|
-
res.status(404).json({ error: 'Thread not found' });
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
// 2. Resolve billing config + inference log discovery
|
|
253
|
-
const billing = await resolveBilling(ctx);
|
|
254
|
-
const inferenceLog = await getInferenceLogInfo(ctx.pool, ctx.api.dbname);
|
|
255
|
-
const ollama = resolveOllamaAdapter();
|
|
256
|
-
if (!ollama) {
|
|
257
|
-
res.status(503).json({ error: 'No LLM provider configured' });
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
const model = body.model ?? threadRow.model ?? ollama.model;
|
|
261
|
-
const meterSlug = model;
|
|
262
|
-
if (billing) {
|
|
263
|
-
const allowed = await checkQuota(ctx, billing, entityId, meterSlug);
|
|
264
|
-
if (!allowed) {
|
|
265
|
-
res.status(429).json({
|
|
266
|
-
error: 'Token quota exceeded',
|
|
267
|
-
meter: meterSlug,
|
|
268
|
-
entity_id: entityId,
|
|
269
|
-
});
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
// 3. Persist user message(s)
|
|
274
|
-
await ctx.withPgClient(async (client) => {
|
|
275
|
-
for (const msg of body.messages) {
|
|
276
|
-
if (msg.role === 'user') {
|
|
277
|
-
await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
278
|
-
(thread_id, owner_id, entity_id, author_role, parts)
|
|
279
|
-
VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4)`, [
|
|
280
|
-
threadId,
|
|
281
|
-
userId,
|
|
282
|
-
'user',
|
|
283
|
-
JSON.stringify([{ type: 'text', text: msg.content }]),
|
|
284
|
-
]);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
});
|
|
288
|
-
// 4. Load full thread history for context
|
|
289
|
-
const history = await ctx.withPgClient(async (client) => {
|
|
290
|
-
const { rows } = await client.query(`SELECT author_role, parts, created_at
|
|
291
|
-
FROM "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
292
|
-
WHERE thread_id = $1
|
|
293
|
-
ORDER BY created_at ASC`, [threadId]);
|
|
294
|
-
return rows;
|
|
295
|
-
});
|
|
296
|
-
const llmMessages = [];
|
|
297
|
-
const systemPrompt = threadRow.system_prompt;
|
|
298
|
-
if (systemPrompt) {
|
|
299
|
-
llmMessages.push({ role: 'system', content: systemPrompt });
|
|
300
|
-
}
|
|
301
|
-
for (const row of history) {
|
|
302
|
-
const parts = Array.isArray(row.parts) ? row.parts : [];
|
|
303
|
-
const textContent = parts
|
|
304
|
-
.filter((p) => p.type === 'text')
|
|
305
|
-
.map((p) => p.text)
|
|
306
|
-
.join('');
|
|
307
|
-
if (textContent) {
|
|
308
|
-
llmMessages.push({
|
|
309
|
-
role: row.author_role === 'user' ? 'user' : 'assistant',
|
|
310
|
-
content: textContent,
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
// 5. Call LLM with token usage tracking
|
|
315
|
-
const shouldStream = body.stream !== false;
|
|
316
|
-
const startTime = Date.now();
|
|
317
|
-
if (shouldStream) {
|
|
318
|
-
// ── SSE Streaming via OllamaAdapter ─────────────────────────────────
|
|
319
|
-
res.writeHead(200, {
|
|
320
|
-
'Content-Type': 'text/event-stream',
|
|
321
|
-
'Cache-Control': 'no-cache',
|
|
322
|
-
'Connection': 'keep-alive',
|
|
323
|
-
'X-Accel-Buffering': 'no',
|
|
324
|
-
});
|
|
325
|
-
const messageId = `msg_${Date.now()}`;
|
|
326
|
-
try {
|
|
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, {
|
|
341
|
-
temperature: body.temperature,
|
|
342
|
-
});
|
|
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();
|
|
360
|
-
const content = streamedContent;
|
|
361
|
-
const latencyMs = Date.now() - startTime;
|
|
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
|
-
};
|
|
370
|
-
// Send [DONE] marker
|
|
371
|
-
res.write('data: [DONE]\n\n');
|
|
372
|
-
res.end();
|
|
373
|
-
// 6. Persist assistant message with model (fire-and-forget)
|
|
374
|
-
if (content) {
|
|
375
|
-
ctx.withPgClient(async (client) => {
|
|
376
|
-
await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
377
|
-
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
378
|
-
VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
|
|
379
|
-
threadId,
|
|
380
|
-
userId,
|
|
381
|
-
'assistant',
|
|
382
|
-
JSON.stringify([{ type: 'text', text: content }]),
|
|
383
|
-
model,
|
|
384
|
-
]);
|
|
385
|
-
}).catch((err) => {
|
|
386
|
-
log.error('[llm-api] Failed to persist assistant message:', err);
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
// 7. Record billing usage (fire-and-forget)
|
|
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,
|
|
396
|
-
model,
|
|
397
|
-
latency_ms: latencyMs,
|
|
398
|
-
stream: true,
|
|
399
|
-
}).catch(() => { });
|
|
400
|
-
}
|
|
401
|
-
// 8. Inference log (fire-and-forget)
|
|
402
|
-
if (inferenceLog) {
|
|
403
|
-
logInference(ctx, inferenceLog, {
|
|
404
|
-
entityId,
|
|
405
|
-
actorId: userId,
|
|
406
|
-
model,
|
|
407
|
-
provider: 'ollama',
|
|
408
|
-
service: 'llm',
|
|
409
|
-
operation: 'chat',
|
|
410
|
-
inputTokens: usage.input,
|
|
411
|
-
outputTokens: usage.output,
|
|
412
|
-
totalTokens: usage.totalTokens,
|
|
413
|
-
latencyMs,
|
|
414
|
-
status: 'ok',
|
|
415
|
-
}).catch(() => { });
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
catch (streamErr) {
|
|
419
|
-
log.error('[llm-api] Streaming error:', streamErr);
|
|
420
|
-
const errorEvent = { error: { message: streamErr.message, type: 'stream_error' } };
|
|
421
|
-
res.write(`data: ${JSON.stringify(errorEvent)}\n\n`);
|
|
422
|
-
res.write('data: [DONE]\n\n');
|
|
423
|
-
res.end();
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
else {
|
|
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, {
|
|
442
|
-
temperature: body.temperature,
|
|
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('');
|
|
449
|
-
const latencyMs = Date.now() - startTime;
|
|
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
|
-
};
|
|
458
|
-
// Persist assistant message with model
|
|
459
|
-
await ctx.withPgClient(async (client) => {
|
|
460
|
-
await client.query(`INSERT INTO "${msgTable.schemaName}"."${msgTable.tableName}"
|
|
461
|
-
(thread_id, owner_id, entity_id, author_role, parts, model)
|
|
462
|
-
VALUES ($1, $2, (SELECT entity_id FROM "${thread.schemaName}"."${thread.tableName}" WHERE id = $1), $3, $4, $5)`, [
|
|
463
|
-
threadId,
|
|
464
|
-
userId,
|
|
465
|
-
'assistant',
|
|
466
|
-
JSON.stringify([{ type: 'text', text: content }]),
|
|
467
|
-
model,
|
|
468
|
-
]);
|
|
469
|
-
});
|
|
470
|
-
// Record billing usage
|
|
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,
|
|
477
|
-
model,
|
|
478
|
-
latency_ms: latencyMs,
|
|
479
|
-
stream: false,
|
|
480
|
-
}).catch(() => { });
|
|
481
|
-
}
|
|
482
|
-
// Inference log
|
|
483
|
-
if (inferenceLog) {
|
|
484
|
-
logInference(ctx, inferenceLog, {
|
|
485
|
-
entityId,
|
|
486
|
-
actorId: userId,
|
|
487
|
-
model,
|
|
488
|
-
provider: 'ollama',
|
|
489
|
-
service: 'llm',
|
|
490
|
-
operation: 'chat',
|
|
491
|
-
inputTokens: usage.input,
|
|
492
|
-
outputTokens: usage.output,
|
|
493
|
-
totalTokens: usage.totalTokens,
|
|
494
|
-
latencyMs,
|
|
495
|
-
status: 'ok',
|
|
496
|
-
}).catch(() => { });
|
|
497
|
-
}
|
|
498
|
-
res.json({
|
|
499
|
-
id: `msg_${Date.now()}`,
|
|
500
|
-
choices: [{
|
|
501
|
-
index: 0,
|
|
502
|
-
message: { role: 'assistant', content },
|
|
503
|
-
finish_reason: 'stop',
|
|
504
|
-
}],
|
|
505
|
-
model,
|
|
506
|
-
usage: {
|
|
507
|
-
prompt_tokens: usage.input,
|
|
508
|
-
completion_tokens: usage.output,
|
|
509
|
-
total_tokens: usage.totalTokens,
|
|
510
|
-
},
|
|
511
|
-
});
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
// ─── Router Factory ─────────────────────────────────────────────────────────
|
|
515
|
-
function createLlmApiRouter() {
|
|
516
|
-
const router = (0, express_1.Router)();
|
|
517
|
-
router.use(express_1.default.json());
|
|
518
|
-
// ── Entity-scoped routes ─────────────────────────────────────────────────
|
|
519
|
-
router.post('/v1/orgs/:entity_id/threads', async (req, res) => {
|
|
520
|
-
try {
|
|
521
|
-
await handleCreateThread(req, res, req.params.entity_id);
|
|
522
|
-
}
|
|
523
|
-
catch (err) {
|
|
524
|
-
log.error('[llm-api] Error creating thread:', err);
|
|
525
|
-
if (!res.headersSent) {
|
|
526
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
});
|
|
530
|
-
router.post('/v1/orgs/:entity_id/threads/:thread_id/messages', async (req, res) => {
|
|
531
|
-
try {
|
|
532
|
-
await handleSendMessage(req, res, req.params.entity_id);
|
|
533
|
-
}
|
|
534
|
-
catch (err) {
|
|
535
|
-
log.error('[llm-api] Error in messages endpoint:', err);
|
|
536
|
-
if (!res.headersSent) {
|
|
537
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
});
|
|
541
|
-
// ── Global routes (no entity_id — bills to actor_id from JWT) ────────────
|
|
542
|
-
router.post('/v1/threads', async (req, res) => {
|
|
543
|
-
try {
|
|
544
|
-
const userId = req.constructive?.userId;
|
|
545
|
-
if (!userId) {
|
|
546
|
-
res.status(401).json({ error: 'Authentication required' });
|
|
547
|
-
return;
|
|
548
|
-
}
|
|
549
|
-
await handleCreateThread(req, res, userId);
|
|
550
|
-
}
|
|
551
|
-
catch (err) {
|
|
552
|
-
log.error('[llm-api] Error creating thread:', err);
|
|
553
|
-
if (!res.headersSent) {
|
|
554
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
});
|
|
558
|
-
router.post('/v1/threads/:thread_id/messages', async (req, res) => {
|
|
559
|
-
try {
|
|
560
|
-
const userId = req.constructive?.userId;
|
|
561
|
-
if (!userId) {
|
|
562
|
-
res.status(401).json({ error: 'Authentication required' });
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
await handleSendMessage(req, res, userId);
|
|
566
|
-
}
|
|
567
|
-
catch (err) {
|
|
568
|
-
log.error('[llm-api] Error in messages endpoint:', err);
|
|
569
|
-
if (!res.headersSent) {
|
|
570
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
});
|
|
574
|
-
return router;
|
|
575
|
-
}
|
package/middleware/upload.d.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import type { PgpmOptions } from '@pgpmjs/types';
|
|
2
|
-
import type { RequestHandler } from 'express';
|
|
3
|
-
import './types';
|
|
4
|
-
/**
|
|
5
|
-
* Upload-specific authentication middleware.
|
|
6
|
-
*
|
|
7
|
-
* This middleware enforces strict auth semantics for `POST /upload` while
|
|
8
|
-
* preserving existing GraphQL auth behavior for other routes.
|
|
9
|
-
*/
|
|
10
|
-
export declare const createUploadAuthenticateMiddleware: (opts: PgpmOptions) => RequestHandler;
|
|
11
|
-
/**
|
|
12
|
-
* REST file upload endpoint.
|
|
13
|
-
*
|
|
14
|
-
* Accepts a single file via multipart/form-data, streams it to S3/MinIO,
|
|
15
|
-
* and returns file metadata. The frontend uses this in a two-step flow:
|
|
16
|
-
*
|
|
17
|
-
* 1. POST /upload -> { url, filename, mime, size }
|
|
18
|
-
* 2. GraphQL mutation -> patch row with the returned metadata
|
|
19
|
-
*/
|
|
20
|
-
export declare const uploadRoute: RequestHandler[];
|