Skip to content

Commit 41db3b7

Browse files
committed
feat: inference metering + agentic server integration tests
- Add inference-meter.ts: fire-and-forget INSERT into platform_usage_log_inferences - Wire inference metering into agentic-server router (pgPool option) - Track model, provider, tokens, latency_ms, status for every LLM call - 10 inference-meter unit tests (columns, rounding, error handling, fire-and-forget) - 12 agentic-server integration tests (proxy, metering, error paths, identity stripping) - Both chat/completions and embeddings endpoints fully metered - Metering never blocks the response (fire-and-forget pattern)
1 parent 4d06aa0 commit 41db3b7

7 files changed

Lines changed: 851 additions & 13 deletions

File tree

packages/agentic-server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
},
2525
"devDependencies": {
2626
"@types/node": "^22.10.4",
27+
"@types/pg": "^8.11.0",
28+
"pg": "^8.20.0",
2729
"rimraf": "^5.0.5",
2830
"typescript": "^5.1.6"
2931
}

packages/agentic-server/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@
1313

1414
export { createAgenticServer } from './server';
1515
export { createRouter } from './router';
16+
export { logInferenceUsage } from './inference-meter';
17+
export type { InferenceEntry } from './inference-meter';
18+
export type { AgenticRouterOptions } from './router';
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Inference metering — fire-and-forget usage logging for LLM calls.
3+
*
4+
* Logs to `constructive_usage_public.platform_usage_log_inferences`
5+
* after each chat completion or embedding request.
6+
*
7+
* All writes are non-blocking: errors are logged and swallowed so
8+
* metering never affects inference latency or response delivery.
9+
*/
10+
11+
import { Logger } from '@pgpmjs/logger';
12+
import type { Pool } from 'pg';
13+
import { randomUUID } from 'crypto';
14+
15+
const log = new Logger('inference-meter');
16+
17+
export interface InferenceEntry {
18+
databaseId?: string;
19+
entityId?: string;
20+
actorId?: string;
21+
requestId?: string;
22+
model: string;
23+
provider: string;
24+
service: 'chat' | 'embed';
25+
operation: string;
26+
inputTokens: number;
27+
outputTokens: number;
28+
totalTokens: number;
29+
latencyMs: number;
30+
status: 'ok' | 'error';
31+
errorType?: string;
32+
rawUsage?: unknown;
33+
}
34+
35+
/**
36+
* Log an inference invocation to the usage log table.
37+
* Fire-and-forget: returns immediately, never throws.
38+
*/
39+
export function logInferenceUsage(pool: Pool, entry: InferenceEntry): void {
40+
const id = randomUUID();
41+
const now = new Date();
42+
43+
pool
44+
.query(
45+
`INSERT INTO "constructive_usage_public".platform_usage_log_inferences
46+
(id, database_id, entity_id, actor_id, request_id,
47+
model, provider, service, operation,
48+
input_tokens, output_tokens, total_tokens,
49+
latency_ms, status, error_type, raw_usage, created_at)
50+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
51+
[
52+
id,
53+
entry.databaseId ?? null,
54+
entry.entityId ?? null,
55+
entry.actorId ?? null,
56+
entry.requestId ?? randomUUID(),
57+
entry.model,
58+
entry.provider,
59+
entry.service,
60+
entry.operation,
61+
entry.inputTokens,
62+
entry.outputTokens,
63+
entry.totalTokens,
64+
Math.round(entry.latencyMs),
65+
entry.status,
66+
entry.errorType ?? null,
67+
entry.rawUsage ? JSON.stringify(entry.rawUsage) : null,
68+
now
69+
]
70+
)
71+
.catch((err) => {
72+
log.warn(`inference log failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
73+
});
74+
}

packages/agentic-server/src/router.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { Router } from 'express';
22
import { Logger } from '@pgpmjs/logger';
3+
import type { Pool } from 'pg';
4+
import { logInferenceUsage } from './inference-meter';
35

46
const log = new Logger('agentic-server');
57

@@ -12,6 +14,8 @@ export interface AgenticRouterOptions {
1214
defaultModel?: string;
1315
/** Provider type: 'openai' | 'ollama' | 'anthropic' */
1416
providerType?: string;
17+
/** Optional pg pool for inference metering (fire-and-forget writes) */
18+
pgPool?: Pool;
1519
}
1620

1721
/**
@@ -25,7 +29,7 @@ export interface AgenticRouterOptions {
2529
*/
2630
export const createRouter = (options: AgenticRouterOptions): Router => {
2731
const router = Router();
28-
const { providerBaseUrl, providerApiKey, defaultModel, providerType } = options;
32+
const { providerBaseUrl, providerApiKey, defaultModel, providerType, pgPool } = options;
2933

3034
// Resolve upstream URL based on provider type
3135
const resolveUpstreamUrl = (path: string): string => {
@@ -112,6 +116,8 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
112116
const internalService = req.get('X-Internal-Service');
113117
const databaseId = req.get('X-Database-Id');
114118
const entityId = req.get('X-Entity-Id');
119+
const actorId = req.get('X-Actor-Id');
120+
const startTime = process.hrtime.bigint();
115121

116122
log.info('chat/completions', {
117123
internal: !!internalService,
@@ -137,9 +143,26 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
137143
body: JSON.stringify(body)
138144
});
139145

146+
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
147+
140148
if (!upstream.ok) {
141149
const text = await upstream.text().catch(() => '');
142150
log.error('upstream error', { status: upstream.status, body: text });
151+
152+
if (pgPool) {
153+
logInferenceUsage(pgPool, {
154+
databaseId, entityId, actorId,
155+
model: String(body.model || ''),
156+
provider: providerType || 'openai',
157+
service: 'chat',
158+
operation: 'chat/completions',
159+
inputTokens: 0, outputTokens: 0, totalTokens: 0,
160+
latencyMs,
161+
status: 'error',
162+
errorType: `upstream_${upstream.status}`
163+
});
164+
}
165+
143166
res.status(upstream.status).json({
144167
error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
145168
});
@@ -150,7 +173,6 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
150173
const data = await upstream.json() as Record<string, unknown>;
151174
const response = transformChatResponse(data, type);
152175

153-
// Log usage for billing (async, don't block response)
154176
const usage = (response.usage || {}) as Record<string, number>;
155177
log.info('inference complete', {
156178
databaseId,
@@ -161,9 +183,41 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
161183
totalTokens: usage.total_tokens
162184
});
163185

186+
if (pgPool) {
187+
logInferenceUsage(pgPool, {
188+
databaseId, entityId, actorId,
189+
model: String(body.model || ''),
190+
provider: providerType || 'openai',
191+
service: 'chat',
192+
operation: 'chat/completions',
193+
inputTokens: usage.prompt_tokens || 0,
194+
outputTokens: usage.completion_tokens || 0,
195+
totalTokens: usage.total_tokens || 0,
196+
latencyMs,
197+
status: 'ok',
198+
rawUsage: usage
199+
});
200+
}
201+
164202
res.json(response);
165203
} catch (err: any) {
204+
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
166205
log.error('chat/completions error', err);
206+
207+
if (pgPool) {
208+
logInferenceUsage(pgPool, {
209+
databaseId, entityId, actorId,
210+
model: String(req.body?.model || ''),
211+
provider: providerType || 'openai',
212+
service: 'chat',
213+
operation: 'chat/completions',
214+
inputTokens: 0, outputTokens: 0, totalTokens: 0,
215+
latencyMs,
216+
status: 'error',
217+
errorType: err.message
218+
});
219+
}
220+
167221
res.status(502).json({
168222
error: { message: 'Failed to reach LLM provider', details: err.message }
169223
});
@@ -174,6 +228,8 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
174228
router.post('/v1/embeddings', async (req: any, res: any) => {
175229
const databaseId = req.get('X-Database-Id');
176230
const entityId = req.get('X-Entity-Id');
231+
const actorId = req.get('X-Actor-Id');
232+
const startTime = process.hrtime.bigint();
177233

178234
log.info('embeddings', { databaseId, entityId, model: req.body?.model });
179235

@@ -194,9 +250,26 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
194250
body: JSON.stringify(body)
195251
});
196252

253+
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
254+
197255
if (!upstream.ok) {
198256
const text = await upstream.text().catch(() => '');
199257
log.error('upstream embed error', { status: upstream.status, body: text });
258+
259+
if (pgPool) {
260+
logInferenceUsage(pgPool, {
261+
databaseId, entityId, actorId,
262+
model: String(body.model || ''),
263+
provider: providerType || 'openai',
264+
service: 'embed',
265+
operation: 'embeddings',
266+
inputTokens: 0, outputTokens: 0, totalTokens: 0,
267+
latencyMs,
268+
status: 'error',
269+
errorType: `upstream_${upstream.status}`
270+
});
271+
}
272+
200273
res.status(upstream.status).json({
201274
error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
202275
});
@@ -207,11 +280,44 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
207280
const data = await upstream.json() as Record<string, unknown>;
208281
const response = transformEmbedResponse(data, type);
209282

283+
const usage = (response.usage || {}) as Record<string, number>;
210284
log.info('embed complete', { databaseId, entityId });
211285

286+
if (pgPool) {
287+
logInferenceUsage(pgPool, {
288+
databaseId, entityId, actorId,
289+
model: String(body.model || ''),
290+
provider: providerType || 'openai',
291+
service: 'embed',
292+
operation: 'embeddings',
293+
inputTokens: usage.prompt_tokens || 0,
294+
outputTokens: 0,
295+
totalTokens: usage.total_tokens || 0,
296+
latencyMs,
297+
status: 'ok',
298+
rawUsage: usage
299+
});
300+
}
301+
212302
res.json(response);
213303
} catch (err: any) {
304+
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
214305
log.error('embeddings error', err);
306+
307+
if (pgPool) {
308+
logInferenceUsage(pgPool, {
309+
databaseId, entityId, actorId,
310+
model: String(req.body?.model || ''),
311+
provider: providerType || 'openai',
312+
service: 'embed',
313+
operation: 'embeddings',
314+
inputTokens: 0, outputTokens: 0, totalTokens: 0,
315+
latencyMs,
316+
status: 'error',
317+
errorType: err.message
318+
});
319+
}
320+
215321
res.status(502).json({
216322
error: { message: 'Failed to reach LLM provider', details: err.message }
217323
});

pnpm-lock.yaml

Lines changed: 29 additions & 11 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)