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/router.js CHANGED
@@ -1,522 +1,267 @@
1
1
  "use strict";
2
2
  /**
3
- * router — Express router for the agentic-server
3
+ * Express router — OpenAI-compatible LLM proxy with multi-provider routing.
4
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.
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.createAgenticRouter = createAgenticRouter;
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 = __importStar(require("express"));
59
- const llm_env_1 = require("@constructive-io/llm-env");
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
- 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
- });
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
- // 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
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
- 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`);
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
- 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
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
- log.error('Error creating thread:', err);
465
- if (!res.headersSent)
466
- res.status(500).json({ error: 'Internal server error' });
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
- 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' });
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
- // Global routes (entity_id = user_id from JWT)
480
- router.post('/v1/threads', async (req, res) => {
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 userId = req.constructive?.userId;
483
- if (!userId) {
484
- res.status(401).json({ error: 'Authentication required' });
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 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;
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
- await handleSendMessage(req, res, userId);
182
+ res.json(responseBody);
503
183
  }
504
184
  catch (err) {
505
- log.error('Error in messages endpoint:', err);
506
- if (!res.headersSent)
507
- res.status(500).json({ error: 'Internal server error' });
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
- // Embedding endpoint
511
- router.post('/v1/embed', async (req, res) => {
512
- try {
513
- await handleEmbed(req, res);
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
- catch (err) {
516
- log.error('Error in embed endpoint:', err);
517
- if (!res.headersSent)
518
- res.status(500).json({ error: 'Internal server error' });
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;