agentic-server 0.21.2 → 0.22.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/README.md +41 -48
- package/esm/index.js +17 -17
- package/esm/providers.js +77 -0
- package/esm/router.js +236 -459
- package/esm/server.js +31 -0
- package/esm/transforms.js +111 -0
- package/esm/types.js +1 -0
- package/index.d.ts +19 -19
- package/index.js +22 -21
- package/package.json +9 -16
- package/providers.d.ts +8 -0
- package/providers.js +82 -0
- package/router.d.ts +10 -18
- package/router.js +238 -493
- package/server.d.ts +10 -0
- package/server.js +38 -0
- package/transforms.d.ts +11 -0
- package/transforms.js +117 -0
- package/types.d.ts +64 -0
- package/types.js +2 -0
package/router.js
CHANGED
|
@@ -1,522 +1,267 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* router —
|
|
3
|
+
* Express router — OpenAI-compatible LLM proxy with multi-provider routing.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - Multiple providers (OpenAI, Anthropic, Ollama) configured at startup
|
|
7
|
+
* - Model-based routing: "anthropic/claude-3.5-sonnet" → anthropic provider
|
|
8
|
+
* - Header-based routing: X-LLM-Provider: ollama → ollama provider
|
|
9
|
+
* - Fire-and-forget inference metering via an injected InferenceSink
|
|
10
|
+
* - /v1/usage reporting endpoint for external usage submission
|
|
20
11
|
*/
|
|
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
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
-
exports.
|
|
56
|
-
const ollama_1 = require("@agentic-kit/ollama");
|
|
13
|
+
exports.createRouter = void 0;
|
|
57
14
|
const logger_1 = require("@pgpmjs/logger");
|
|
58
|
-
const express_1 =
|
|
59
|
-
const
|
|
15
|
+
const express_1 = require("express");
|
|
16
|
+
const providers_1 = require("./providers");
|
|
17
|
+
const transforms_1 = require("./transforms");
|
|
60
18
|
const log = new logger_1.Logger('agentic-server');
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
});
|
|
19
|
+
const createRouter = (options) => {
|
|
20
|
+
const router = (0, express_1.Router)();
|
|
21
|
+
// Metering is backend-agnostic: the caller injects an InferenceSink. When
|
|
22
|
+
// none is provided, no usage is recorded.
|
|
23
|
+
const sink = options.inferenceSink;
|
|
24
|
+
// POST /v1/chat/completions — multi-provider LLM proxy
|
|
25
|
+
router.post('/v1/chat/completions', async (req, res) => {
|
|
26
|
+
const databaseId = req.get('X-Database-Id');
|
|
27
|
+
if (!databaseId) {
|
|
28
|
+
res.status(400).json({ error: { message: 'X-Database-Id is required' } });
|
|
164
29
|
return;
|
|
165
30
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
31
|
+
const entityId = req.get('X-Entity-Id');
|
|
32
|
+
const actorId = req.get('X-Actor-Id');
|
|
33
|
+
const requestProvider = req.get('X-LLM-Provider');
|
|
34
|
+
const startTime = process.hrtime.bigint();
|
|
35
|
+
const provider = (0, providers_1.resolveProvider)(options, req.body?.model, requestProvider);
|
|
36
|
+
log.info('chat/completions', {
|
|
37
|
+
databaseId,
|
|
38
|
+
entityId,
|
|
39
|
+
provider: provider.type,
|
|
40
|
+
model: req.body?.model
|
|
244
41
|
});
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
};
|
|
258
|
-
|
|
42
|
+
try {
|
|
43
|
+
const upstreamUrl = (0, providers_1.resolveUpstreamUrl)(provider, '/v1/chat/completions');
|
|
44
|
+
const body = (0, transforms_1.transformChatRequest)(provider, req.body || {});
|
|
45
|
+
const headers = (0, providers_1.buildProviderHeaders)(provider);
|
|
46
|
+
const upstream = await fetch(upstreamUrl, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers,
|
|
49
|
+
body: JSON.stringify(body)
|
|
50
|
+
});
|
|
51
|
+
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
|
|
52
|
+
if (!upstream.ok) {
|
|
53
|
+
const text = await upstream.text().catch(() => '');
|
|
54
|
+
log.error('upstream error', { status: upstream.status, provider: provider.type, body: text });
|
|
55
|
+
if (sink) {
|
|
56
|
+
sink.logInference({
|
|
57
|
+
databaseId, entityId, actorId,
|
|
58
|
+
model: String(req.body?.model || body.model || ''),
|
|
59
|
+
provider: provider.type,
|
|
60
|
+
service: 'chat',
|
|
61
|
+
operation: 'chat/completions',
|
|
62
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0,
|
|
63
|
+
latencyMs,
|
|
64
|
+
status: 'error',
|
|
65
|
+
errorType: `upstream_${upstream.status}`
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
res.status(upstream.status).json({
|
|
69
|
+
error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
259
72
|
}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
73
|
+
const data = await upstream.json();
|
|
74
|
+
const { body: responseBody, usage } = (0, transforms_1.transformChatResponse)(data, provider);
|
|
75
|
+
log.info('inference complete', {
|
|
76
|
+
databaseId,
|
|
77
|
+
provider: provider.type,
|
|
78
|
+
model: req.body?.model,
|
|
79
|
+
promptTokens: usage.prompt_tokens,
|
|
80
|
+
completionTokens: usage.completion_tokens
|
|
81
|
+
});
|
|
82
|
+
if (sink) {
|
|
83
|
+
sink.logInference({
|
|
84
|
+
databaseId, entityId, actorId,
|
|
85
|
+
model: String(req.body?.model || body.model || ''),
|
|
86
|
+
provider: provider.type,
|
|
87
|
+
service: 'chat',
|
|
88
|
+
operation: 'chat/completions',
|
|
89
|
+
inputTokens: usage.prompt_tokens || 0,
|
|
90
|
+
outputTokens: usage.completion_tokens || 0,
|
|
91
|
+
totalTokens: usage.total_tokens || 0,
|
|
92
|
+
latencyMs,
|
|
93
|
+
status: 'ok',
|
|
94
|
+
rawUsage: usage
|
|
95
|
+
});
|
|
446
96
|
}
|
|
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);
|
|
97
|
+
res.json(responseBody);
|
|
462
98
|
}
|
|
463
99
|
catch (err) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
100
|
+
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
|
|
101
|
+
log.error('chat/completions error', err);
|
|
102
|
+
if (sink) {
|
|
103
|
+
sink.logInference({
|
|
104
|
+
databaseId, entityId, actorId,
|
|
105
|
+
model: String(req.body?.model || ''),
|
|
106
|
+
provider: provider.type,
|
|
107
|
+
service: 'chat',
|
|
108
|
+
operation: 'chat/completions',
|
|
109
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0,
|
|
110
|
+
latencyMs,
|
|
111
|
+
status: 'error',
|
|
112
|
+
errorType: err.message
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
res.status(502).json({
|
|
116
|
+
error: { message: 'Failed to reach LLM provider', details: err.message }
|
|
117
|
+
});
|
|
467
118
|
}
|
|
468
119
|
});
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
if (!res.headersSent)
|
|
476
|
-
res.status(500).json({ error: 'Internal server error' });
|
|
120
|
+
// POST /v1/embeddings — multi-provider embedding proxy
|
|
121
|
+
router.post('/v1/embeddings', async (req, res) => {
|
|
122
|
+
const databaseId = req.get('X-Database-Id');
|
|
123
|
+
if (!databaseId) {
|
|
124
|
+
res.status(400).json({ error: { message: 'X-Database-Id is required' } });
|
|
125
|
+
return;
|
|
477
126
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
127
|
+
const entityId = req.get('X-Entity-Id');
|
|
128
|
+
const actorId = req.get('X-Actor-Id');
|
|
129
|
+
const requestProvider = req.get('X-LLM-Provider');
|
|
130
|
+
const startTime = process.hrtime.bigint();
|
|
131
|
+
const provider = (0, providers_1.resolveProvider)(options, req.body?.model, requestProvider);
|
|
132
|
+
log.info('embeddings', { databaseId, entityId, provider: provider.type, model: req.body?.model });
|
|
481
133
|
try {
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
134
|
+
const upstreamUrl = (0, providers_1.resolveUpstreamUrl)(provider, '/v1/embeddings');
|
|
135
|
+
const body = (0, transforms_1.transformEmbedRequest)(provider, req.body || {});
|
|
136
|
+
const headers = (0, providers_1.buildProviderHeaders)(provider);
|
|
137
|
+
const upstream = await fetch(upstreamUrl, {
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers,
|
|
140
|
+
body: JSON.stringify(body)
|
|
141
|
+
});
|
|
142
|
+
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
|
|
143
|
+
if (!upstream.ok) {
|
|
144
|
+
const text = await upstream.text().catch(() => '');
|
|
145
|
+
log.error('upstream embed error', { status: upstream.status, provider: provider.type });
|
|
146
|
+
if (sink) {
|
|
147
|
+
sink.logInference({
|
|
148
|
+
databaseId, entityId, actorId,
|
|
149
|
+
model: String(req.body?.model || body.model || ''),
|
|
150
|
+
provider: provider.type,
|
|
151
|
+
service: 'embed',
|
|
152
|
+
operation: 'embeddings',
|
|
153
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0,
|
|
154
|
+
latencyMs,
|
|
155
|
+
status: 'error',
|
|
156
|
+
errorType: `upstream_${upstream.status}`
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
res.status(upstream.status).json({
|
|
160
|
+
error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
|
|
161
|
+
});
|
|
485
162
|
return;
|
|
486
163
|
}
|
|
487
|
-
await
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
164
|
+
const data = await upstream.json();
|
|
165
|
+
const { body: responseBody, usage } = (0, transforms_1.transformEmbedResponse)(data, provider);
|
|
166
|
+
log.info('embed complete', { databaseId, entityId, provider: provider.type });
|
|
167
|
+
if (sink) {
|
|
168
|
+
sink.logInference({
|
|
169
|
+
databaseId, entityId, actorId,
|
|
170
|
+
model: String(req.body?.model || body.model || ''),
|
|
171
|
+
provider: provider.type,
|
|
172
|
+
service: 'embed',
|
|
173
|
+
operation: 'embeddings',
|
|
174
|
+
inputTokens: usage.prompt_tokens || 0,
|
|
175
|
+
outputTokens: 0,
|
|
176
|
+
totalTokens: usage.total_tokens || 0,
|
|
177
|
+
latencyMs,
|
|
178
|
+
status: 'ok',
|
|
179
|
+
rawUsage: usage
|
|
180
|
+
});
|
|
501
181
|
}
|
|
502
|
-
|
|
182
|
+
res.json(responseBody);
|
|
503
183
|
}
|
|
504
184
|
catch (err) {
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
185
|
+
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
|
|
186
|
+
log.error('embeddings error', err);
|
|
187
|
+
if (sink) {
|
|
188
|
+
sink.logInference({
|
|
189
|
+
databaseId, entityId, actorId,
|
|
190
|
+
model: String(req.body?.model || ''),
|
|
191
|
+
provider: provider.type,
|
|
192
|
+
service: 'embed',
|
|
193
|
+
operation: 'embeddings',
|
|
194
|
+
inputTokens: 0, outputTokens: 0, totalTokens: 0,
|
|
195
|
+
latencyMs,
|
|
196
|
+
status: 'error',
|
|
197
|
+
errorType: err.message
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
res.status(502).json({
|
|
201
|
+
error: { message: 'Failed to reach LLM provider', details: err.message }
|
|
202
|
+
});
|
|
508
203
|
}
|
|
509
204
|
});
|
|
510
|
-
//
|
|
511
|
-
router.post('/v1/
|
|
512
|
-
|
|
513
|
-
|
|
205
|
+
// POST /v1/usage — external usage reporting endpoint
|
|
206
|
+
router.post('/v1/usage', (req, res) => {
|
|
207
|
+
const databaseId = req.get('X-Database-Id');
|
|
208
|
+
if (!databaseId) {
|
|
209
|
+
res.status(400).json({ error: { message: 'X-Database-Id is required' } });
|
|
210
|
+
return;
|
|
514
211
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
212
|
+
const entityId = req.get('X-Entity-Id');
|
|
213
|
+
const actorId = req.get('X-Actor-Id');
|
|
214
|
+
const { model, provider: reportedProvider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status, error_type, raw_usage } = req.body || {};
|
|
215
|
+
if (!model) {
|
|
216
|
+
res.status(400).json({ error: { message: 'model is required' } });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (sink) {
|
|
220
|
+
sink.logInference({
|
|
221
|
+
databaseId,
|
|
222
|
+
entityId,
|
|
223
|
+
actorId,
|
|
224
|
+
model: String(model),
|
|
225
|
+
provider: String(reportedProvider || 'unknown'),
|
|
226
|
+
service: (service === 'embed' ? 'embed' : 'chat'),
|
|
227
|
+
operation: operation || 'external',
|
|
228
|
+
inputTokens: Number(input_tokens) || 0,
|
|
229
|
+
outputTokens: Number(output_tokens) || 0,
|
|
230
|
+
totalTokens: Number(total_tokens) || 0,
|
|
231
|
+
latencyMs: Number(latency_ms) || 0,
|
|
232
|
+
status: status === 'error' ? 'error' : 'ok',
|
|
233
|
+
errorType: error_type || undefined,
|
|
234
|
+
rawUsage: raw_usage || undefined
|
|
235
|
+
});
|
|
519
236
|
}
|
|
237
|
+
res.status(202).json({ accepted: true });
|
|
238
|
+
});
|
|
239
|
+
// GET /v1/providers — list configured providers
|
|
240
|
+
router.get('/v1/providers', (_req, res) => {
|
|
241
|
+
const providers = options.providers || [{
|
|
242
|
+
type: options.providerType || 'ollama',
|
|
243
|
+
baseUrl: options.providerBaseUrl || 'http://localhost:11434'
|
|
244
|
+
}];
|
|
245
|
+
res.json({
|
|
246
|
+
providers: providers.map((p) => ({
|
|
247
|
+
type: p.type,
|
|
248
|
+
defaultModel: p.defaultModel
|
|
249
|
+
}))
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
// GET /healthz — health check
|
|
253
|
+
router.get('/healthz', (_req, res) => {
|
|
254
|
+
const providers = options.providers || [{
|
|
255
|
+
type: options.providerType || 'ollama',
|
|
256
|
+
baseUrl: options.providerBaseUrl || 'http://localhost:11434'
|
|
257
|
+
}];
|
|
258
|
+
res.json({
|
|
259
|
+
status: 'ok',
|
|
260
|
+
provider: providers[0]?.type || 'ollama',
|
|
261
|
+
providerUrl: providers[0]?.baseUrl || options.providerBaseUrl,
|
|
262
|
+
providers: providers.map((p) => p.type)
|
|
263
|
+
});
|
|
520
264
|
});
|
|
521
265
|
return router;
|
|
522
|
-
}
|
|
266
|
+
};
|
|
267
|
+
exports.createRouter = createRouter;
|