agentic-server 0.21.2 → 0.22.1

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/router.js CHANGED
@@ -1,486 +1,263 @@
1
1
  /**
2
- * router — Express router for the agentic-server
2
+ * Express router — OpenAI-compatible LLM proxy with multi-provider routing.
3
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.
4
+ * Supports:
5
+ * - Multiple providers (OpenAI, Anthropic, Ollama) configured at startup
6
+ * - Model-based routing: "anthropic/claude-3.5-sonnet" → anthropic provider
7
+ * - Header-based routing: X-LLM-Provider: ollama → ollama provider
8
+ * - Fire-and-forget inference metering via an injected InferenceSink
9
+ * - /v1/usage reporting endpoint for external usage submission
19
10
  */
20
- import { OllamaAdapter } from '@agentic-kit/ollama';
21
11
  import { Logger } from '@pgpmjs/logger';
22
- import express, { Router } from 'express';
23
- import { getEnvOptions as getLlmEnvOptions } from '@constructive-io/llm-env';
12
+ import { Router } from 'express';
13
+ import { buildProviderHeaders, resolveProvider, resolveUpstreamUrl } from './providers';
14
+ import { transformChatRequest, transformChatResponse, transformEmbedRequest, transformEmbedResponse } from './transforms';
24
15
  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
- });
16
+ export const createRouter = (options) => {
17
+ const router = Router();
18
+ // Metering is backend-agnostic: the caller injects an InferenceSink. When
19
+ // none is provided, no usage is recorded.
20
+ const sink = options.inferenceSink;
21
+ // POST /v1/chat/completions — multi-provider LLM proxy
22
+ router.post('/v1/chat/completions', async (req, res) => {
23
+ const databaseId = req.get('X-Database-Id');
24
+ if (!databaseId) {
25
+ res.status(400).json({ error: { message: 'X-Database-Id is required' } });
128
26
  return;
129
27
  }
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
28
+ const entityId = req.get('X-Entity-Id');
29
+ const actorId = req.get('X-Actor-Id');
30
+ const requestProvider = req.get('X-LLM-Provider');
31
+ const startTime = process.hrtime.bigint();
32
+ const provider = resolveProvider(options, req.body?.model, requestProvider);
33
+ log.info('chat/completions', {
34
+ databaseId,
35
+ entityId,
36
+ provider: provider.type,
37
+ model: req.body?.model
208
38
  });
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`);
39
+ try {
40
+ const upstreamUrl = resolveUpstreamUrl(provider, '/v1/chat/completions');
41
+ const body = transformChatRequest(provider, req.body || {});
42
+ const headers = buildProviderHeaders(provider);
43
+ const upstream = await fetch(upstreamUrl, {
44
+ method: 'POST',
45
+ headers,
46
+ body: JSON.stringify(body)
47
+ });
48
+ const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
49
+ if (!upstream.ok) {
50
+ const text = await upstream.text().catch(() => '');
51
+ log.error('upstream error', { status: upstream.status, provider: provider.type, body: text });
52
+ if (sink) {
53
+ sink.logInference({
54
+ databaseId, entityId, actorId,
55
+ model: String(req.body?.model || body.model || ''),
56
+ provider: provider.type,
57
+ service: 'chat',
58
+ operation: 'chat/completions',
59
+ inputTokens: 0, outputTokens: 0, totalTokens: 0,
60
+ latencyMs,
61
+ status: 'error',
62
+ errorType: `upstream_${upstream.status}`
63
+ });
64
+ }
65
+ res.status(upstream.status).json({
66
+ error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
67
+ });
68
+ return;
223
69
  }
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
70
+ const data = await upstream.json();
71
+ const { body: responseBody, usage } = transformChatResponse(data, provider);
72
+ log.info('inference complete', {
73
+ databaseId,
74
+ provider: provider.type,
75
+ model: req.body?.model,
76
+ promptTokens: usage.prompt_tokens,
77
+ completionTokens: usage.completion_tokens
78
+ });
79
+ if (sink) {
80
+ sink.logInference({
81
+ databaseId, entityId, actorId,
82
+ model: String(req.body?.model || body.model || ''),
83
+ provider: provider.type,
84
+ service: 'chat',
85
+ operation: 'chat/completions',
86
+ inputTokens: usage.prompt_tokens || 0,
87
+ outputTokens: usage.completion_tokens || 0,
88
+ totalTokens: usage.total_tokens || 0,
89
+ latencyMs,
90
+ status: 'ok',
91
+ rawUsage: usage
92
+ });
410
93
  }
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);
94
+ res.json(responseBody);
426
95
  }
427
96
  catch (err) {
428
- log.error('Error creating thread:', err);
429
- if (!res.headersSent)
430
- res.status(500).json({ error: 'Internal server error' });
97
+ const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
98
+ log.error('chat/completions error', err);
99
+ if (sink) {
100
+ sink.logInference({
101
+ databaseId, entityId, actorId,
102
+ model: String(req.body?.model || ''),
103
+ provider: provider.type,
104
+ service: 'chat',
105
+ operation: 'chat/completions',
106
+ inputTokens: 0, outputTokens: 0, totalTokens: 0,
107
+ latencyMs,
108
+ status: 'error',
109
+ errorType: err.message
110
+ });
111
+ }
112
+ res.status(502).json({
113
+ error: { message: 'Failed to reach LLM provider', details: err.message }
114
+ });
431
115
  }
432
116
  });
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' });
117
+ // POST /v1/embeddings — multi-provider embedding proxy
118
+ router.post('/v1/embeddings', async (req, res) => {
119
+ const databaseId = req.get('X-Database-Id');
120
+ if (!databaseId) {
121
+ res.status(400).json({ error: { message: 'X-Database-Id is required' } });
122
+ return;
441
123
  }
442
- });
443
- // Global routes (entity_id = user_id from JWT)
444
- router.post('/v1/threads', async (req, res) => {
124
+ const entityId = req.get('X-Entity-Id');
125
+ const actorId = req.get('X-Actor-Id');
126
+ const requestProvider = req.get('X-LLM-Provider');
127
+ const startTime = process.hrtime.bigint();
128
+ const provider = resolveProvider(options, req.body?.model, requestProvider);
129
+ log.info('embeddings', { databaseId, entityId, provider: provider.type, model: req.body?.model });
445
130
  try {
446
- const userId = req.constructive?.userId;
447
- if (!userId) {
448
- res.status(401).json({ error: 'Authentication required' });
131
+ const upstreamUrl = resolveUpstreamUrl(provider, '/v1/embeddings');
132
+ const body = transformEmbedRequest(provider, req.body || {});
133
+ const headers = buildProviderHeaders(provider);
134
+ const upstream = await fetch(upstreamUrl, {
135
+ method: 'POST',
136
+ headers,
137
+ body: JSON.stringify(body)
138
+ });
139
+ const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
140
+ if (!upstream.ok) {
141
+ const text = await upstream.text().catch(() => '');
142
+ log.error('upstream embed error', { status: upstream.status, provider: provider.type });
143
+ if (sink) {
144
+ sink.logInference({
145
+ databaseId, entityId, actorId,
146
+ model: String(req.body?.model || body.model || ''),
147
+ provider: provider.type,
148
+ service: 'embed',
149
+ operation: 'embeddings',
150
+ inputTokens: 0, outputTokens: 0, totalTokens: 0,
151
+ latencyMs,
152
+ status: 'error',
153
+ errorType: `upstream_${upstream.status}`
154
+ });
155
+ }
156
+ res.status(upstream.status).json({
157
+ error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
158
+ });
449
159
  return;
450
160
  }
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;
161
+ const data = await upstream.json();
162
+ const { body: responseBody, usage } = transformEmbedResponse(data, provider);
163
+ log.info('embed complete', { databaseId, entityId, provider: provider.type });
164
+ if (sink) {
165
+ sink.logInference({
166
+ databaseId, entityId, actorId,
167
+ model: String(req.body?.model || body.model || ''),
168
+ provider: provider.type,
169
+ service: 'embed',
170
+ operation: 'embeddings',
171
+ inputTokens: usage.prompt_tokens || 0,
172
+ outputTokens: 0,
173
+ totalTokens: usage.total_tokens || 0,
174
+ latencyMs,
175
+ status: 'ok',
176
+ rawUsage: usage
177
+ });
465
178
  }
466
- await handleSendMessage(req, res, userId);
179
+ res.json(responseBody);
467
180
  }
468
181
  catch (err) {
469
- log.error('Error in messages endpoint:', err);
470
- if (!res.headersSent)
471
- res.status(500).json({ error: 'Internal server error' });
182
+ const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
183
+ log.error('embeddings error', err);
184
+ if (sink) {
185
+ sink.logInference({
186
+ databaseId, entityId, actorId,
187
+ model: String(req.body?.model || ''),
188
+ provider: provider.type,
189
+ service: 'embed',
190
+ operation: 'embeddings',
191
+ inputTokens: 0, outputTokens: 0, totalTokens: 0,
192
+ latencyMs,
193
+ status: 'error',
194
+ errorType: err.message
195
+ });
196
+ }
197
+ res.status(502).json({
198
+ error: { message: 'Failed to reach LLM provider', details: err.message }
199
+ });
472
200
  }
473
201
  });
474
- // Embedding endpoint
475
- router.post('/v1/embed', async (req, res) => {
476
- try {
477
- await handleEmbed(req, res);
202
+ // POST /v1/usage — external usage reporting endpoint
203
+ router.post('/v1/usage', (req, res) => {
204
+ const databaseId = req.get('X-Database-Id');
205
+ if (!databaseId) {
206
+ res.status(400).json({ error: { message: 'X-Database-Id is required' } });
207
+ return;
478
208
  }
479
- catch (err) {
480
- log.error('Error in embed endpoint:', err);
481
- if (!res.headersSent)
482
- res.status(500).json({ error: 'Internal server error' });
209
+ const entityId = req.get('X-Entity-Id');
210
+ const actorId = req.get('X-Actor-Id');
211
+ const { model, provider: reportedProvider, service, operation, input_tokens, output_tokens, total_tokens, latency_ms, status, error_type, raw_usage } = req.body || {};
212
+ if (!model) {
213
+ res.status(400).json({ error: { message: 'model is required' } });
214
+ return;
215
+ }
216
+ if (sink) {
217
+ sink.logInference({
218
+ databaseId,
219
+ entityId,
220
+ actorId,
221
+ model: String(model),
222
+ provider: String(reportedProvider || 'unknown'),
223
+ service: (service === 'embed' ? 'embed' : 'chat'),
224
+ operation: operation || 'external',
225
+ inputTokens: Number(input_tokens) || 0,
226
+ outputTokens: Number(output_tokens) || 0,
227
+ totalTokens: Number(total_tokens) || 0,
228
+ latencyMs: Number(latency_ms) || 0,
229
+ status: status === 'error' ? 'error' : 'ok',
230
+ errorType: error_type || undefined,
231
+ rawUsage: raw_usage || undefined
232
+ });
483
233
  }
234
+ res.status(202).json({ accepted: true });
235
+ });
236
+ // GET /v1/providers — list configured providers
237
+ router.get('/v1/providers', (_req, res) => {
238
+ const providers = options.providers || [{
239
+ type: options.providerType || 'ollama',
240
+ baseUrl: options.providerBaseUrl || 'http://localhost:11434'
241
+ }];
242
+ res.json({
243
+ providers: providers.map((p) => ({
244
+ type: p.type,
245
+ defaultModel: p.defaultModel
246
+ }))
247
+ });
248
+ });
249
+ // GET /healthz — health check
250
+ router.get('/healthz', (_req, res) => {
251
+ const providers = options.providers || [{
252
+ type: options.providerType || 'ollama',
253
+ baseUrl: options.providerBaseUrl || 'http://localhost:11434'
254
+ }];
255
+ res.json({
256
+ status: 'ok',
257
+ provider: providers[0]?.type || 'ollama',
258
+ providerUrl: providers[0]?.baseUrl || options.providerBaseUrl,
259
+ providers: providers.map((p) => p.type)
260
+ });
484
261
  });
485
262
  return router;
486
- }
263
+ };