agentic-server 0.21.1 → 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/esm/router.js
CHANGED
|
@@ -1,486 +1,263 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* router —
|
|
2
|
+
* Express router — OpenAI-compatible LLM proxy with multi-provider routing.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
23
|
-
import {
|
|
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
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
};
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
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
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
179
|
+
res.json(responseBody);
|
|
467
180
|
}
|
|
468
181
|
catch (err) {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
//
|
|
475
|
-
router.post('/v1/
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
+
};
|