Skip to content

Commit 88f17f8

Browse files
committed
fix: make agentic-server self-contained — remove knative-job-worker dependency
inference-meter.ts now resolves table names directly from MetaSchema instead of importing UsageClient from the worker package. This removes the circular dependency and fixes the CI build.
1 parent e205530 commit 88f17f8

6 files changed

Lines changed: 157 additions & 48 deletions

File tree

packages/agentic-server/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
"start": "node dist/standalone.js"
2020
},
2121
"dependencies": {
22-
"@constructive-io/knative-job-worker": "workspace:*",
2322
"@pgpmjs/logger": "^2.4.3",
2423
"express": "5.2.1"
2524
},
Lines changed: 126 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,147 @@
11
/**
22
* Inference metering — fire-and-forget usage logging for LLM calls.
33
*
4-
* Delegates to UsageClient which resolves table names dynamically from
5-
* MetaSchema module registration tables. The schema-qualified reference
6-
* for the inference usage log is discovered at runtime, not hardcoded.
4+
* Self-contained implementation that resolves table names dynamically
5+
* from MetaSchema module registration tables. Falls back to well-known
6+
* defaults when MetaSchema is unavailable.
77
*
88
* All writes are non-blocking: errors are logged and swallowed so
99
* metering never affects inference latency or response delivery.
1010
*/
1111

12+
import { Logger } from '@pgpmjs/logger';
13+
import { randomUUID } from 'crypto';
1214
import type { Pool } from 'pg';
1315

14-
import { UsageClient } from '@constructive-io/knative-job-worker';
15-
import type { InferenceEntry } from '@constructive-io/knative-job-worker';
16+
const log = new Logger('inference-meter');
1617

17-
export type { InferenceEntry };
18+
// ─── Types ────────────────────────────────────────────────────────────────────
1819

19-
/** Module-level client cache — reused across calls within the same pool. */
20-
let _client: UsageClient | null = null;
21-
let _pool: Pool | null = null;
20+
export interface InferenceEntry {
21+
databaseId?: string;
22+
entityId?: string;
23+
actorId?: string;
24+
requestId?: string;
25+
model: string;
26+
provider: string;
27+
service: 'chat' | 'embed';
28+
operation: string;
29+
inputTokens: number;
30+
outputTokens: number;
31+
totalTokens: number;
32+
latencyMs: number;
33+
status: 'ok' | 'error';
34+
errorType?: string;
35+
rawUsage?: unknown;
36+
}
37+
38+
// ─── Table Resolution ─────────────────────────────────────────────────────────
39+
40+
const INFERENCE_TABLE_SQL = `
41+
SELECT s.schema_name, t.name AS table_name
42+
FROM metaschema_public."table" t
43+
JOIN metaschema_public.schema s ON t.schema_id = s.id
44+
WHERE t.database_id = $1
45+
AND t.name = 'platform_usage_log_inferences'
46+
LIMIT 1
47+
`;
48+
49+
const DEFAULT_SCHEMA = 'constructive_usage_public';
50+
const DEFAULT_TABLE = 'platform_usage_log_inferences';
51+
const DEFAULT_DATABASE_ID = '00000000-0000-0000-0000-000000000000';
52+
const CONFIG_TTL_MS = 60_000;
53+
54+
interface TableRef {
55+
schema: string;
56+
table: string;
57+
}
58+
59+
let _cachedRef: TableRef | null = null;
60+
let _cacheExpiresAt = 0;
61+
let _resolvePromise: Promise<TableRef> | null = null;
2262

23-
function getOrCreateClient(pool: Pool): UsageClient {
24-
if (_client && _pool === pool) return _client;
25-
_client = new UsageClient(pool);
26-
_pool = pool;
27-
return _client;
63+
/** Reset the module-level cache (for testing). */
64+
export function _resetCache(): void {
65+
_cachedRef = null;
66+
_cacheExpiresAt = 0;
67+
_resolvePromise = null;
2868
}
2969

70+
async function resolveTable(pool: Pool, databaseId: string): Promise<TableRef> {
71+
if (_cachedRef && Date.now() < _cacheExpiresAt) return _cachedRef;
72+
if (_resolvePromise) return _resolvePromise;
73+
74+
_resolvePromise = (async () => {
75+
try {
76+
const { rows } = await pool.query(INFERENCE_TABLE_SQL, [databaseId]);
77+
const ref: TableRef =
78+
rows.length > 0
79+
? { schema: rows[0].schema_name, table: rows[0].table_name }
80+
: { schema: DEFAULT_SCHEMA, table: DEFAULT_TABLE };
81+
_cachedRef = ref;
82+
_cacheExpiresAt = Date.now() + CONFIG_TTL_MS;
83+
_resolvePromise = null;
84+
return ref;
85+
} catch {
86+
log.debug('metaschema lookup unavailable — using default table names');
87+
const ref: TableRef = { schema: DEFAULT_SCHEMA, table: DEFAULT_TABLE };
88+
_cachedRef = ref;
89+
_cacheExpiresAt = Date.now() + CONFIG_TTL_MS;
90+
_resolvePromise = null;
91+
return ref;
92+
}
93+
})();
94+
95+
return _resolvePromise;
96+
}
97+
98+
// ─── Public API ───────────────────────────────────────────────────────────────
99+
30100
/**
31101
* Log an inference invocation to the usage log table.
32102
* Fire-and-forget: returns immediately, never throws.
33103
*/
34104
export function logInferenceUsage(pool: Pool, entry: InferenceEntry): void {
35-
getOrCreateClient(pool).logInferenceUsage(entry);
105+
const dbId = entry.databaseId ?? DEFAULT_DATABASE_ID;
106+
107+
resolveTable(pool, dbId)
108+
.then((ref) => {
109+
const id = randomUUID();
110+
const now = new Date();
111+
112+
pool
113+
.query(
114+
`INSERT INTO "${ref.schema}"."${ref.table}"
115+
(id, database_id, entity_id, actor_id, request_id,
116+
model, provider, service, operation,
117+
input_tokens, output_tokens, total_tokens,
118+
latency_ms, status, error_type, raw_usage, created_at)
119+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)`,
120+
[
121+
id,
122+
entry.databaseId ?? null,
123+
entry.entityId ?? null,
124+
entry.actorId ?? null,
125+
entry.requestId ?? randomUUID(),
126+
entry.model,
127+
entry.provider,
128+
entry.service,
129+
entry.operation,
130+
entry.inputTokens,
131+
entry.outputTokens,
132+
entry.totalTokens,
133+
Math.round(entry.latencyMs),
134+
entry.status,
135+
entry.errorType ?? null,
136+
entry.rawUsage ? JSON.stringify(entry.rawUsage) : null,
137+
now,
138+
]
139+
)
140+
.catch((err) => {
141+
log.warn(`inference log failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
142+
});
143+
})
144+
.catch((err) => {
145+
log.warn(`inference usage log failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
146+
});
36147
}

packages/agentic-server/src/router.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Router } from 'express';
22
import { Logger } from '@pgpmjs/logger';
33
import type { Pool } from 'pg';
4-
import { UsageClient } from '@constructive-io/knative-job-worker';
4+
import { logInferenceUsage } from './inference-meter';
55

66
const log = new Logger('agentic-server');
77

@@ -30,7 +30,6 @@ export interface AgenticRouterOptions {
3030
export const createRouter = (options: AgenticRouterOptions): Router => {
3131
const router = Router();
3232
const { providerBaseUrl, providerApiKey, defaultModel, providerType, pgPool } = options;
33-
const usageClient = pgPool ? new UsageClient(pgPool) : null;
3433

3534
// Resolve upstream URL based on provider type
3635
const resolveUpstreamUrl = (path: string): string => {
@@ -150,8 +149,8 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
150149
const text = await upstream.text().catch(() => '');
151150
log.error('upstream error', { status: upstream.status, body: text });
152151

153-
if (usageClient) {
154-
usageClient.logInferenceUsage({
152+
if (pgPool) {
153+
logInferenceUsage(pgPool, {
155154
databaseId, entityId, actorId,
156155
model: String(body.model || ''),
157156
provider: providerType || 'openai',
@@ -184,8 +183,8 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
184183
totalTokens: usage.total_tokens
185184
});
186185

187-
if (usageClient) {
188-
usageClient.logInferenceUsage({
186+
if (pgPool) {
187+
logInferenceUsage(pgPool, {
189188
databaseId, entityId, actorId,
190189
model: String(body.model || ''),
191190
provider: providerType || 'openai',
@@ -205,8 +204,8 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
205204
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
206205
log.error('chat/completions error', err);
207206

208-
if (usageClient) {
209-
usageClient.logInferenceUsage({
207+
if (pgPool) {
208+
logInferenceUsage(pgPool, {
210209
databaseId, entityId, actorId,
211210
model: String(req.body?.model || ''),
212211
provider: providerType || 'openai',
@@ -257,8 +256,8 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
257256
const text = await upstream.text().catch(() => '');
258257
log.error('upstream embed error', { status: upstream.status, body: text });
259258

260-
if (usageClient) {
261-
usageClient.logInferenceUsage({
259+
if (pgPool) {
260+
logInferenceUsage(pgPool, {
262261
databaseId, entityId, actorId,
263262
model: String(body.model || ''),
264263
provider: providerType || 'openai',
@@ -284,8 +283,8 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
284283
const usage = (response.usage || {}) as Record<string, number>;
285284
log.info('embed complete', { databaseId, entityId });
286285

287-
if (usageClient) {
288-
usageClient.logInferenceUsage({
286+
if (pgPool) {
287+
logInferenceUsage(pgPool, {
289288
databaseId, entityId, actorId,
290289
model: String(body.model || ''),
291290
provider: providerType || 'openai',
@@ -305,8 +304,8 @@ export const createRouter = (options: AgenticRouterOptions): Router => {
305304
const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
306305
log.error('embeddings error', err);
307306

308-
if (usageClient) {
309-
usageClient.logInferenceUsage({
307+
if (pgPool) {
308+
logInferenceUsage(pgPool, {
310309
databaseId, entityId, actorId,
311310
model: String(req.body?.model || ''),
312311
provider: providerType || 'openai',

pnpm-lock.yaml

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

tests/integration/agentic-server.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import express from 'express';
1515
import http from 'http';
1616
import { createAgenticServer } from '../../packages/agentic-server/src/server';
17+
import { _resetCache } from '../../packages/agentic-server/src/inference-meter';
1718

1819
const flush = () => new Promise((r) => setTimeout(r, 30));
1920

@@ -67,6 +68,7 @@ describe('agentic server (first-class service)', () => {
6768
});
6869

6970
beforeEach(() => {
71+
_resetCache();
7072
mockQuery = jest.fn().mockResolvedValue({ rows: [] });
7173
mockPool = { query: mockQuery } as any;
7274
mockProvider.calls.length = 0;
@@ -156,7 +158,7 @@ describe('agentic server (first-class service)', () => {
156158
// 2 calls: 1 config resolution (metaschema) + 1 insert
157159
expect(mockQuery).toHaveBeenCalledTimes(2);
158160
const insertCall = mockQuery.mock.calls.find(
159-
([sql]: [string]) => sql.includes('platform_usage_log_inferences')
161+
([sql]: [string]) => sql.includes('INSERT INTO')
160162
);
161163
expect(insertCall).toBeDefined();
162164
const [sql, params] = insertCall!;
@@ -185,7 +187,7 @@ describe('agentic server (first-class service)', () => {
185187
await flush();
186188

187189
const insertCall = mockQuery.mock.calls.find(
188-
([sql]: [string]) => sql.includes('platform_usage_log_inferences')
190+
([sql]: [string]) => sql.includes('INSERT INTO')
189191
);
190192
const [, params] = insertCall!;
191193
const latencyMs = params[12];
@@ -253,7 +255,7 @@ describe('agentic server (first-class service)', () => {
253255
// 2 calls: 1 config resolution (metaschema) + 1 insert
254256
expect(mockQuery).toHaveBeenCalledTimes(2);
255257
const insertCall = mockQuery.mock.calls.find(
256-
([sql]: [string]) => sql.includes('platform_usage_log_inferences')
258+
([sql]: [string]) => sql.includes('INSERT INTO')
257259
);
258260
expect(insertCall).toBeDefined();
259261
const [sql, params] = insertCall!;
@@ -284,7 +286,7 @@ describe('agentic server (first-class service)', () => {
284286
// Error path: 1 config resolution + 1 insert
285287
expect(mockQuery).toHaveBeenCalledTimes(2);
286288
const insertCall = mockQuery.mock.calls.find(
287-
([sql]: [string]) => sql.includes('platform_usage_log_inferences')
289+
([sql]: [string]) => sql.includes('INSERT INTO')
288290
);
289291
const [, params] = insertCall!;
290292
expect(params[13]).toBe('error'); // status
@@ -344,7 +346,7 @@ describe('agentic server (first-class service)', () => {
344346

345347
// Metering should have null database_id (stripped → undefined → null via ?? null)
346348
const insertCall = mockQuery.mock.calls.find(
347-
([sql]: [string]) => sql.includes('platform_usage_log_inferences')
349+
([sql]: [string]) => sql.includes('INSERT INTO')
348350
);
349351
const [, params] = insertCall!;
350352
expect(params[1]).toBeNull(); // database_id stripped

0 commit comments

Comments
 (0)