Skip to content

Commit 580d2f0

Browse files
heskewclaude
andcommitted
feat(models): add backend registry, TestBackend, hdb_model_calls writer
Part of #628. Three scaffolding files consumed by the upcoming Models facade — none of them are reachable from app code until that lands. - `backendRegistry.ts`: process-wide module-scope registry. Backends register themselves by `backend.name`. `resolveEmbedding(logicalName)` and `resolveGenerative(logicalName)` read `models.<kind>.<logicalName>.backend` from config and return the registered instance. Errors are `ServerError` subclasses (`ModelBackendNotConfiguredError`, `ModelBackendNotRegisteredError`); the not-registered error deliberately does not enumerate registered backends to avoid leaking the registry shape in error responses. - `TestBackend.ts`: deterministic in-memory backend for Phase 1 tests. `embed()` returns 16-dim Float32Arrays seeded by a FNV-1a hash of the input (same text → same vector across runs). `generate()` echoes with a prefix; `generateStream()` yields word-by-word. - `analyticsTable.ts`: lazy-getter declaration of `hdb_model_calls` matching the convention in `analytics/write.ts:656-700` and `DurableSubscriptionsSession.ts:14-50`. Per-call buffered writer: flush every 10s OR at 1000 rows, separate hourly cleanup at 90-day retention (configurable via `analytics.modelCallRetentionDays`). Both intervals `.unref()`ed; on shutdown, buffered rows are dropped (matches existing analytics-writer posture). No call sites yet. The Models facade in the next commit ties registry + writer + ALS context together. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cea6c1e commit 580d2f0

3 files changed

Lines changed: 334 additions & 0 deletions

File tree

resources/models/TestBackend.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import type {
2+
BackendOpts,
3+
EmbedOpts,
4+
GenerateChunk,
5+
GenerateInput,
6+
GenerateOpts,
7+
GenerateResult,
8+
ModelBackend,
9+
ModelCallResult,
10+
ModelCapabilities,
11+
} from './types.ts';
12+
13+
/**
14+
* Deterministic in-memory backend for Phase 1 tests.
15+
*
16+
* Same input always produces the same output — no external services, no model
17+
* files, no network. Lets the facade, registry, accounting, and analytics
18+
* paths be exercised end-to-end without an Ollama / OpenAI / fabric backend.
19+
*/
20+
export class TestBackend implements ModelBackend {
21+
readonly name = 'test';
22+
23+
capabilities(): ModelCapabilities {
24+
return { embed: true, generate: true, stream: true, tools: false, adapters: false };
25+
}
26+
27+
async embed(input: string | string[], _opts: BackendOpts<EmbedOpts>): Promise<ModelCallResult<Float32Array[]>> {
28+
const texts = Array.isArray(input) ? input : [input];
29+
const vectors = texts.map((t) => deterministicVector(t, 16));
30+
const embeddingTokens = texts.reduce((sum, t) => sum + t.length, 0);
31+
return { status: 'completed', output: vectors, usage: { embeddingTokens, latencyMs: 0 } };
32+
}
33+
34+
async generate(input: GenerateInput, _opts: BackendOpts<GenerateOpts>): Promise<ModelCallResult<GenerateResult>> {
35+
const text = stringFromInput(input);
36+
const content = `[TestBackend echoed]: ${text}`;
37+
return {
38+
status: 'completed',
39+
output: { content, finishReason: 'stop' },
40+
usage: { promptTokens: text.length, completionTokens: content.length, latencyMs: 0 },
41+
};
42+
}
43+
44+
async *generateStream(input: GenerateInput, _opts: BackendOpts<GenerateOpts>): AsyncIterable<GenerateChunk> {
45+
const text = stringFromInput(input);
46+
const words = `[TestBackend stream]: ${text}`.split(' ');
47+
for (const word of words) {
48+
yield { deltaContent: word + ' ' };
49+
}
50+
yield { finishReason: 'stop' };
51+
}
52+
}
53+
54+
function stringFromInput(input: GenerateInput): string {
55+
if (typeof input === 'string') return input;
56+
const messages = Array.isArray(input) ? input : input.messages;
57+
return messages.map((m) => m.content).join(' ');
58+
}
59+
60+
/**
61+
* Hash text into a deterministic Float32Array of the given dimension.
62+
*
63+
* Uses FNV-1a to seed a Mulberry32 PRNG; values are mapped to [-1, 1).
64+
* Same input → same vector across runs and platforms; not a cryptographic hash.
65+
*/
66+
function deterministicVector(text: string, dim: number): Float32Array {
67+
let seed = 2166136261 >>> 0; // FNV-1a 32-bit offset basis
68+
for (let i = 0; i < text.length; i++) {
69+
seed ^= text.charCodeAt(i);
70+
seed = Math.imul(seed, 16777619) >>> 0;
71+
}
72+
const vec = new Float32Array(dim);
73+
let state = seed;
74+
for (let i = 0; i < dim; i++) {
75+
state = (state + 0x6d2b79f5) >>> 0;
76+
let t = state;
77+
t = Math.imul(t ^ (t >>> 15), t | 1) >>> 0;
78+
t = (t + Math.imul(t ^ (t >>> 7), t | 61)) >>> 0;
79+
const r = ((t ^ (t >>> 14)) >>> 0) / 4294967296;
80+
vec[i] = r * 2 - 1; // map [0, 1) → [-1, 1)
81+
}
82+
return vec;
83+
}

resources/models/analyticsTable.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { table } from '../databases.ts';
2+
import { get as envGet } from '../../utility/environment/environmentManager.ts';
3+
import { getNextMonotonicTime } from '../../utility/lmdb/commonUtility.ts';
4+
import harperLogger from '../../utility/logging/harper_logger.ts';
5+
6+
const log = harperLogger.forComponent('models').conditional;
7+
8+
const DEFAULT_FLUSH_INTERVAL_MS = 10_000; // 10s
9+
const DEFAULT_MAX_BUFFER_SIZE = 1000;
10+
const DEFAULT_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1h
11+
const DEFAULT_RETENTION_DAYS = 90;
12+
13+
/**
14+
* One row in `hdb_model_calls`. Field names are snake_case to match the table
15+
* schema. Numeric token counts are optional because not every backend reports
16+
* every metric.
17+
*/
18+
export interface ModelCallRecord {
19+
tenant?: string;
20+
app?: string;
21+
model?: string;
22+
backend: string;
23+
method: 'embed' | 'generate' | 'generateStream';
24+
adapter?: string;
25+
conversation_id?: string;
26+
prompt_tokens?: number;
27+
completion_tokens?: number;
28+
embedding_tokens?: number;
29+
gpu_ms?: number;
30+
latency_ms: number;
31+
success: boolean;
32+
/** Sanitized code (e.g. 'backend_error', 'aborted', 'capability_unsupported'). Never a raw upstream message. */
33+
error_code?: string;
34+
}
35+
36+
interface BufferedRecord extends ModelCallRecord {
37+
id: number;
38+
}
39+
40+
let _table: any;
41+
/**
42+
* Lazy-getter for `hdb_model_calls`. Matches the convention used by
43+
* `getRawAnalyticsTable()` / `getAnalyticsTable()` in
44+
* `resources/analytics/write.ts:656-700` and the system-table declarations in
45+
* `server/DurableSubscriptionsSession.ts:14-50`.
46+
*/
47+
export function getModelCallsTable(): any {
48+
if (_table) return _table;
49+
_table = table({
50+
table: 'hdb_model_calls',
51+
database: 'system',
52+
audit: true,
53+
trackDeletes: false,
54+
attributes: [
55+
{ name: 'id', isPrimaryKey: true },
56+
{ name: 'tenant', type: 'string', indexed: true },
57+
{ name: 'app', type: 'string', indexed: true },
58+
{ name: 'model', type: 'string', indexed: true },
59+
{ name: 'backend', type: 'string', indexed: true },
60+
{ name: 'method', type: 'string', indexed: true },
61+
{ name: 'adapter', type: 'string', indexed: true },
62+
{ name: 'conversation_id', type: 'string', indexed: true },
63+
{ name: 'prompt_tokens', type: 'number' },
64+
{ name: 'completion_tokens', type: 'number' },
65+
{ name: 'embedding_tokens', type: 'number' },
66+
{ name: 'gpu_ms', type: 'number' },
67+
{ name: 'latency_ms', type: 'number', indexed: true },
68+
{ name: 'success', type: 'boolean', indexed: true },
69+
{ name: 'error_code', type: 'string' },
70+
],
71+
});
72+
return _table;
73+
}
74+
75+
export interface ModelCallAnalyticsWriterOpts {
76+
/** Default 10s. Buffer is flushed at this cadence regardless of size. */
77+
flushIntervalMs?: number;
78+
/** Default 1000. Reaching this size triggers an out-of-cadence flush. */
79+
maxBufferSize?: number;
80+
/** Default 1h. How often `cleanup()` runs to remove expired rows. */
81+
cleanupIntervalMs?: number;
82+
/** Default 90d. Rows older than this are removed by `cleanup()`. */
83+
retentionMs?: number;
84+
}
85+
86+
/**
87+
* In-memory buffered writer for per-call model analytics. Rows are batched and
88+
* flushed periodically (or when the buffer is full) to keep the analytics path
89+
* off the hot model-call path. Shape mirrors the pattern in
90+
* `resources/analytics/write.ts` but writes per-call rows rather than
91+
* aggregating counters.
92+
*
93+
* The intervals are `.unref()`ed so they never hold the process open during
94+
* shutdown; rows buffered at shutdown are dropped (best-effort, same posture
95+
* as the existing analytics writer).
96+
*/
97+
export class ModelCallAnalyticsWriter {
98+
#buffer: BufferedRecord[] = [];
99+
#flushTimer?: NodeJS.Timeout;
100+
#cleanupTimer?: NodeJS.Timeout;
101+
#maxBufferSize: number;
102+
#retentionMs: number;
103+
#stopped = false;
104+
105+
constructor(opts: ModelCallAnalyticsWriterOpts = {}) {
106+
const flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
107+
const cleanupIntervalMs = opts.cleanupIntervalMs ?? DEFAULT_CLEANUP_INTERVAL_MS;
108+
this.#maxBufferSize = opts.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE;
109+
this.#retentionMs = opts.retentionMs ?? resolveRetentionMs();
110+
this.#flushTimer = setInterval(() => {
111+
this.flush().catch((err) => log.warn?.(`Model-call analytics flush failed: ${err?.message ?? err}`));
112+
}, flushIntervalMs);
113+
this.#flushTimer.unref?.();
114+
this.#cleanupTimer = setInterval(() => {
115+
this.cleanup().catch((err) => log.warn?.(`Model-call analytics cleanup failed: ${err?.message ?? err}`));
116+
}, cleanupIntervalMs);
117+
this.#cleanupTimer.unref?.();
118+
}
119+
120+
write(record: ModelCallRecord): void {
121+
if (this.#stopped) return;
122+
this.#buffer.push({ id: getNextMonotonicTime(), ...record });
123+
if (this.#buffer.length >= this.#maxBufferSize) {
124+
// Out-of-cadence flush; swallow errors so a failing flush doesn't escape into the caller.
125+
this.flush().catch((err) => log.warn?.(`Model-call analytics flush failed: ${err?.message ?? err}`));
126+
}
127+
}
128+
129+
async flush(): Promise<void> {
130+
if (this.#buffer.length === 0) return;
131+
const batch = this.#buffer;
132+
this.#buffer = [];
133+
const tbl = getModelCallsTable();
134+
for (const record of batch) {
135+
try {
136+
tbl.primaryStore.put(record.id, record);
137+
} catch (err) {
138+
log.warn?.(`Model-call analytics put failed for id=${record.id}: ${(err as Error)?.message ?? err}`);
139+
}
140+
}
141+
}
142+
143+
async cleanup(): Promise<void> {
144+
const end = Date.now() - this.#retentionMs;
145+
const tbl = getModelCallsTable();
146+
for (const key of tbl.primaryStore.getKeys({ start: false, end })) {
147+
tbl.primaryStore.remove(key);
148+
}
149+
}
150+
151+
/** Stop the periodic timers. After stop, `write()` is a no-op and `flush()` still works. */
152+
stop(): void {
153+
this.#stopped = true;
154+
if (this.#flushTimer) clearInterval(this.#flushTimer);
155+
if (this.#cleanupTimer) clearInterval(this.#cleanupTimer);
156+
}
157+
158+
/** Test-only: inspect current buffer size without flushing. */
159+
get bufferSize(): number {
160+
return this.#buffer.length;
161+
}
162+
}
163+
164+
function resolveRetentionMs(): number {
165+
const days = envGet('analytics.modelCallRetentionDays');
166+
const n = typeof days === 'number' && days > 0 ? days : DEFAULT_RETENTION_DAYS;
167+
return n * 24 * 60 * 60 * 1000;
168+
}
169+
170+
let _writer: ModelCallAnalyticsWriter | undefined;
171+
/** Process-wide singleton writer. Constructed on first access. */
172+
export function getModelCallAnalyticsWriter(): ModelCallAnalyticsWriter {
173+
if (!_writer) _writer = new ModelCallAnalyticsWriter();
174+
return _writer;
175+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { get as envGet } from '../../utility/environment/environmentManager.ts';
2+
import { ServerError } from '../../utility/errors/hdbError.ts';
3+
import type { ModelBackend } from './types.ts';
4+
5+
/**
6+
* Process-wide model backend registry.
7+
*
8+
* Backends register themselves by `backend.name` (e.g. `'test'`, `'ollama'`,
9+
* `'ollama:fast'`). Per-kind logical-name resolution is config-driven:
10+
* `resolveEmbedding('default')` reads `models.embedding.default.backend` from
11+
* config and returns the registered backend with that name.
12+
*
13+
* Module-scope state is intentional — one registry per Harper process,
14+
* mirroring `contextStorage` at `resources/transaction.ts:6`.
15+
*/
16+
17+
type ModelKind = 'embedding' | 'generative';
18+
19+
const byName: Map<string, ModelBackend> = new Map();
20+
21+
/**
22+
* Register a backend instance. Idempotent on `backend.name` — re-registering
23+
* with the same name replaces the prior instance (convenient for tests).
24+
*/
25+
export function registerBackend(backend: ModelBackend): void {
26+
byName.set(backend.name, backend);
27+
}
28+
29+
/**
30+
* Resolve the backend configured as the embedding provider for `logicalName`
31+
* (default: `'default'`). Throws if no config entry exists or the named
32+
* backend is not registered.
33+
*/
34+
export function resolveEmbedding(logicalName: string = 'default'): ModelBackend {
35+
return resolve('embedding', logicalName);
36+
}
37+
38+
/**
39+
* Resolve the backend configured as the generative provider for `logicalName`
40+
* (default: `'default'`). Throws if no config entry exists or the named
41+
* backend is not registered.
42+
*/
43+
export function resolveGenerative(logicalName: string = 'default'): ModelBackend {
44+
return resolve('generative', logicalName);
45+
}
46+
47+
/** Remove all registered backends. Test-only hygiene. */
48+
export function clearRegistry(): void {
49+
byName.clear();
50+
}
51+
52+
function resolve(kind: ModelKind, logicalName: string): ModelBackend {
53+
const backendName = envGet(`models.${kind}.${logicalName}.backend`);
54+
if (typeof backendName !== 'string' || !backendName) {
55+
throw new ModelBackendNotConfiguredError(kind, logicalName);
56+
}
57+
const backend = byName.get(backendName);
58+
if (!backend) throw new ModelBackendNotRegisteredError(kind, logicalName);
59+
return backend;
60+
}
61+
62+
export class ModelBackendNotConfiguredError extends ServerError {
63+
constructor(kind: ModelKind, logicalName: string) {
64+
super(`No '${kind}.${logicalName}.backend' configured`);
65+
this.name = 'ModelBackendNotConfiguredError';
66+
}
67+
}
68+
69+
export class ModelBackendNotRegisteredError extends ServerError {
70+
// Deliberately does not name which backend was configured — avoids leaking
71+
// the set of registered backend identifiers in error responses.
72+
constructor(kind: ModelKind, logicalName: string) {
73+
super(`Backend configured for '${kind}.${logicalName}' is not registered`);
74+
this.name = 'ModelBackendNotRegisteredError';
75+
}
76+
}

0 commit comments

Comments
 (0)