|
1 | 1 | /** |
2 | 2 | * Inference metering — fire-and-forget usage logging for LLM calls. |
3 | 3 | * |
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. |
7 | 7 | * |
8 | 8 | * All writes are non-blocking: errors are logged and swallowed so |
9 | 9 | * metering never affects inference latency or response delivery. |
10 | 10 | */ |
11 | 11 |
|
| 12 | +import { Logger } from '@pgpmjs/logger'; |
| 13 | +import { randomUUID } from 'crypto'; |
12 | 14 | import type { Pool } from 'pg'; |
13 | 15 |
|
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'); |
16 | 17 |
|
17 | | -export type { InferenceEntry }; |
| 18 | +// ─── Types ──────────────────────────────────────────────────────────────────── |
18 | 19 |
|
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; |
22 | 62 |
|
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; |
28 | 68 | } |
29 | 69 |
|
| 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 | + |
30 | 100 | /** |
31 | 101 | * Log an inference invocation to the usage log table. |
32 | 102 | * Fire-and-forget: returns immediately, never throws. |
33 | 103 | */ |
34 | 104 | 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 | + }); |
36 | 147 | } |
0 commit comments