Skip to content

Commit 7fe0f23

Browse files
heskewclaude
andcommitted
feat(models): add Models facade and wire scope.models
Part of #628. Ties the registry, analytics writer, and ALS context together as the public `scope.models` facade. The facade reads `contextStorage.getStore()` on every call to extract the per-request accounting context (tenant, handlerPath) and to pull the request's `AbortSignal` when one is bound. Outside an ALS scope (app-init code, internal jobs), accounting is empty and the call proceeds — both successes and failures write one row to `hdb_model_calls` for billing visibility. The class is added to `Scope` as `scope.models`, constructed once per Scope. The analytics writer behind it is the process-wide singleton from `analyticsTable.ts`, so all Scopes share one buffer. Two new `ServerError` subclasses are exported: - `ModelCapabilityError` — backend declares it doesn't support the requested method. Message names only the backend and capability; doesn't enumerate other capabilities to keep registry shape private. - `ModelPendingNotSupportedError` — backend returned `pending`; Phase 1 reserves the shape but has no LRO surface to poll. Lands when the first LRO-capable backend ships. `generateStream` wraps the backend's async iterator so the analytics record is written once when the stream terminates (success or error from any chunk). `error_code` is classified into a small sanitized vocabulary (`aborted`, `capability_unsupported`, `backend_error`) so no raw upstream message ever lands in the table. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 580d2f0 commit 7fe0f23

2 files changed

Lines changed: 218 additions & 0 deletions

File tree

components/Scope.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { server, type Server } from '../server/Server.ts';
66
import { EntryHandler, type EntryHandlerEventMap, type onEntryEventHandler } from './EntryHandler.ts';
77
import { OptionsWatcher, OptionsWatcherEventMap } from './OptionsWatcher.ts';
88
import { resources, type Resources } from '../resources/Resources.ts';
9+
import { Models } from '../resources/models/Models.ts';
910
import type { FileAndURLPathConfig } from './Component.ts';
1011
import { FilesOption } from './deriveGlobOptions.ts';
1112
import { requestRestart } from './requestRestart.ts';
@@ -49,6 +50,7 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
4950
server?: Server;
5051
ready: Promise<any[]>;
5152
databaseEvents: typeof databaseEventsEmitter;
53+
models: Models;
5254

5355
constructor(
5456
appName: string,
@@ -68,6 +70,7 @@ export class Scope extends EventEmitter<ScopeEventsMap> {
6870
this.databaseEvents = databaseEventsEmitter;
6971
this.applicationScope = applicationScope;
7072
this.resources = applicationScope?.resources ?? resources;
73+
this.models = new Models();
7174

7275
const baseServer = applicationScope?.server ?? server;
7376
const scopeRef = this;

resources/models/Models.ts

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { contextStorage } from '../transaction.ts';
2+
import { resolveEmbedding, resolveGenerative } from './backendRegistry.ts';
3+
import { getModelCallAnalyticsWriter, type ModelCallAnalyticsWriter, type ModelCallRecord } from './analyticsTable.ts';
4+
import { ServerError } from '../../utility/errors/hdbError.ts';
5+
import type {
6+
AccountingContext,
7+
BackendOpts,
8+
EmbedOpts,
9+
GenerateChunk,
10+
GenerateInput,
11+
GenerateOpts,
12+
GenerateResult,
13+
ModelBackend,
14+
ModelCallResult,
15+
Models as ModelsContract,
16+
TokenUsage,
17+
} from './types.ts';
18+
19+
type CallMethod = ModelCallRecord['method'];
20+
21+
/**
22+
* Public `scope.models` facade. One instance per `Scope`.
23+
*
24+
* On every call:
25+
* - Resolves the configured backend via `backendRegistry` (config-driven).
26+
* - Reads the ALS-bound request `Context` to extract accounting context
27+
* (tenantId, handlerPath) and an `AbortSignal`. Outside an ALS scope
28+
* (app-init, internal jobs), accounting is empty and signal is undefined.
29+
* - Records the call to `hdb_model_calls` via the buffered writer — both
30+
* successful and failed calls land in the table for billing visibility.
31+
*
32+
* The ALS pattern matches `resources/Table.ts:3517` and is rooted at
33+
* `resources/transaction.ts:6`.
34+
*/
35+
export class Models implements ModelsContract {
36+
#analyticsWriter: ModelCallAnalyticsWriter;
37+
38+
constructor(analyticsWriter: ModelCallAnalyticsWriter = getModelCallAnalyticsWriter()) {
39+
this.#analyticsWriter = analyticsWriter;
40+
}
41+
42+
async embed(input: string | string[], opts: EmbedOpts = {}): Promise<Float32Array[]> {
43+
const backend = resolveEmbedding(opts.model);
44+
requireCapability(backend, 'embed');
45+
const { accounting, signal } = resolveCallContext(opts.signal);
46+
const backendOpts: BackendOpts<EmbedOpts> = { ...opts, signal, accounting };
47+
const startedAt = performance.now();
48+
try {
49+
const result = await backend.embed!(input, backendOpts);
50+
this.#record(backend, 'embed', opts.model, accounting, undefined, result, startedAt);
51+
return unwrap(backend, result);
52+
} catch (err) {
53+
this.#recordFailure(backend, 'embed', opts.model, accounting, undefined, startedAt, err);
54+
throw err;
55+
}
56+
}
57+
58+
async generate(input: GenerateInput, opts: GenerateOpts = {}): Promise<GenerateResult> {
59+
const backend = resolveGenerative(opts.model);
60+
requireCapability(backend, 'generate');
61+
const { accounting, signal } = resolveCallContext(opts.signal);
62+
const backendOpts: BackendOpts<GenerateOpts> = { ...opts, signal, accounting };
63+
const startedAt = performance.now();
64+
try {
65+
const result = await backend.generate!(input, backendOpts);
66+
this.#record(backend, 'generate', opts.model, accounting, opts, result, startedAt);
67+
return unwrap(backend, result);
68+
} catch (err) {
69+
this.#recordFailure(backend, 'generate', opts.model, accounting, opts, startedAt, err);
70+
throw err;
71+
}
72+
}
73+
74+
generateStream(input: GenerateInput, opts: GenerateOpts = {}): AsyncIterable<GenerateChunk> {
75+
const backend = resolveGenerative(opts.model);
76+
requireCapability(backend, 'stream');
77+
const { accounting, signal } = resolveCallContext(opts.signal);
78+
const backendOpts: BackendOpts<GenerateOpts> = { ...opts, signal, accounting };
79+
return this.#wrapStream(backend, input, backendOpts, opts, accounting);
80+
}
81+
82+
async *#wrapStream(
83+
backend: ModelBackend,
84+
input: GenerateInput,
85+
backendOpts: BackendOpts<GenerateOpts>,
86+
opts: GenerateOpts,
87+
accounting: AccountingContext
88+
): AsyncIterable<GenerateChunk> {
89+
const startedAt = performance.now();
90+
let success = true;
91+
let caught: unknown;
92+
try {
93+
for await (const chunk of backend.generateStream!(input, backendOpts)) {
94+
yield chunk;
95+
}
96+
} catch (err) {
97+
success = false;
98+
caught = err;
99+
throw err;
100+
} finally {
101+
if (success) {
102+
this.#record(backend, 'generateStream', opts.model, accounting, opts, undefined, startedAt);
103+
} else {
104+
this.#recordFailure(backend, 'generateStream', opts.model, accounting, opts, startedAt, caught);
105+
}
106+
}
107+
}
108+
109+
#record(
110+
backend: ModelBackend,
111+
method: CallMethod,
112+
model: string | undefined,
113+
accounting: AccountingContext,
114+
opts: GenerateOpts | undefined,
115+
result: ModelCallResult<unknown> | undefined,
116+
startedAt: number
117+
): void {
118+
const usage = result?.status === 'completed' ? result.usage : undefined;
119+
this.#analyticsWriter.write(buildRecord(backend, method, model, accounting, opts, usage, startedAt, true));
120+
}
121+
122+
#recordFailure(
123+
backend: ModelBackend,
124+
method: CallMethod,
125+
model: string | undefined,
126+
accounting: AccountingContext,
127+
opts: GenerateOpts | undefined,
128+
startedAt: number,
129+
err: unknown
130+
): void {
131+
this.#analyticsWriter.write({
132+
...buildRecord(backend, method, model, accounting, opts, undefined, startedAt, false),
133+
error_code: classifyError(err),
134+
});
135+
}
136+
}
137+
138+
function buildRecord(
139+
backend: ModelBackend,
140+
method: CallMethod,
141+
model: string | undefined,
142+
accounting: AccountingContext,
143+
opts: GenerateOpts | undefined,
144+
usage: TokenUsage | undefined,
145+
startedAt: number,
146+
success: boolean
147+
): ModelCallRecord {
148+
const record: ModelCallRecord = {
149+
backend: backend.name,
150+
method,
151+
model,
152+
tenant: accounting.tenantId,
153+
app: accounting.app,
154+
adapter: opts?.adapter,
155+
conversation_id: opts?.conversationId,
156+
latency_ms: performance.now() - startedAt,
157+
success,
158+
};
159+
if (usage) {
160+
if (usage.promptTokens !== undefined) record.prompt_tokens = usage.promptTokens;
161+
if (usage.completionTokens !== undefined) record.completion_tokens = usage.completionTokens;
162+
if (usage.embeddingTokens !== undefined) record.embedding_tokens = usage.embeddingTokens;
163+
if (usage.gpuMs !== undefined) record.gpu_ms = usage.gpuMs;
164+
}
165+
return record;
166+
}
167+
168+
function resolveCallContext(callerSignal?: AbortSignal): { accounting: AccountingContext; signal?: AbortSignal } {
169+
const ctx = contextStorage.getStore() as any;
170+
return {
171+
accounting: {
172+
tenantId: extractTenantId(ctx?.user),
173+
app: ctx?.handlerPath,
174+
},
175+
signal: callerSignal ?? ctx?.signal,
176+
};
177+
}
178+
179+
function extractTenantId(user: any): string | undefined {
180+
return user?.tenant ?? user?.tenantId ?? undefined;
181+
}
182+
183+
function requireCapability(backend: ModelBackend, capability: 'embed' | 'generate' | 'stream'): void {
184+
if (!backend.capabilities()[capability]) throw new ModelCapabilityError(backend.name, capability);
185+
}
186+
187+
function unwrap<T>(backend: ModelBackend, result: ModelCallResult<T>): T {
188+
if (result.status === 'completed') return result.output;
189+
throw new ModelPendingNotSupportedError(backend.name);
190+
}
191+
192+
function classifyError(err: unknown): string {
193+
if (err && typeof err === 'object') {
194+
const e = err as { name?: string; code?: string };
195+
if (e.name === 'AbortError' || e.code === 'ABORT_ERR') return 'aborted';
196+
if (e.name === 'ModelCapabilityError') return 'capability_unsupported';
197+
}
198+
return 'backend_error';
199+
}
200+
201+
export class ModelCapabilityError extends ServerError {
202+
// Deliberately does not name the requested capability beyond what was asked for —
203+
// avoids enumerating what the backend *does* support in error responses.
204+
constructor(backendName: string, capability: 'embed' | 'generate' | 'stream') {
205+
super(`Backend '${backendName}' does not support '${capability}'`);
206+
this.name = 'ModelCapabilityError';
207+
}
208+
}
209+
210+
export class ModelPendingNotSupportedError extends ServerError {
211+
constructor(backendName: string) {
212+
super(`Backend '${backendName}' returned 'pending'; long-running operations are not yet supported`);
213+
this.name = 'ModelPendingNotSupportedError';
214+
}
215+
}

0 commit comments

Comments
 (0)