You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Models facade exposed as scope.models on the Scope class — internally reads contextStorage.getStore() (the existing ALS at resources/Table.ts:3484) to pick up per-request context (tenant, signal, app).
No real backends ship in this phase — interface plus test fixture only. Real backends land in Phases 2 (ollama), 3 (openai), and 8 (anthropic, bedrock).
Why first
Every other phase depends on the interface, the facade, the backend registry, and the analytics writer. Sub-issues that build on this: Phase 2, 3, 4, 5, 8 (#510 sub-issues) and #612 (agent-loop orchestration). External issues that consume the interface: #511 (opts.conversationId activation).
API surface
// resources/models/types.tsexportinterfaceModels{embed(input: string|string[],opts?: EmbedOpts): Promise<Float32Array[]>;generate(input: GenerateInput,opts?: GenerateOpts): Promise<GenerateResult>;generateStream(input: GenerateInput,opts?: GenerateOpts): AsyncIterable<GenerateChunk>;}exportinterfaceModelBackend{readonlyname: string;capabilities(): ModelCapabilities;embed?(input: string|string[],opts: BackendOpts<EmbedOpts>): Promise<ModelCallResult<Float32Array[]>>;generate?(input: GenerateInput,opts: BackendOpts<GenerateOpts>): Promise<ModelCallResult<GenerateResult>>;generateStream?(input: GenerateInput,opts: BackendOpts<GenerateOpts>): AsyncIterable<GenerateChunk>;}exportinterfaceModelCapabilities{embed: boolean;generate: boolean;stream: boolean;tools: boolean;adapters: boolean;}exporttypeEmbedOpts={model?: string;inputType?: 'document'|'query';// for models that distinguish (nomic-embed-text etc.); ignored by models that don'tsignal?: AbortSignal;};exporttypeGenerateOpts={model?: string;adapter?: string;temperature?: number;maxTokens?: number;responseFormat?: 'text'|'json'|{schema: object};tools?: ToolDef[];toolMode?: 'return'|'auto';conversationId?: string;// accepted; inert until #511 landssignal?: AbortSignal;};exporttypeGenerateInput=|string|Message[]|{messages: Message[];tools?: ToolDef[];system?: string};exporttypeBackendOpts<TOpts>=TOpts&{accounting: AccountingContext};exportinterfaceAccountingContext{tenantId?: string;app?: string;}// Backend return type. 'pending' is reserved for future long-running operations// (Bedrock batch, future fabric LRO); no Phase 1 backend emits it. The public// Models facade unwraps 'completed' and matches #510's Promise<Float32Array[]>// / Promise<GenerateResult> signatures.exporttypeModelCallResult<T>=|{status: 'completed';output: T;usage?: TokenUsage}|{status: 'pending';operationId: string;resumeAfter?: number};exportinterfaceTokenUsage{promptTokens?: number;completionTokens?: number;embeddingTokens?: number;gpuMs?: number;latencyMs?: number;}// Message, ToolDef, ToolCall, GenerateResult, GenerateChunk: concrete types as in #510's API surface section
Implementation pattern: ALS-backed scope.models
The developer writes scope.models.embed(text) from anywhere in their component — handleApplication(scope) init code, Resource methods (via closure), nested helpers. ALS provides per-request context when called inside a Resource scope; falls back to no-tenant accounting when called outside one.
// components/Scope.ts (modified)exportclassScopeextendsEventEmitter{readonlymodels: Models;constructor(/* existing args */){super();// existing initthis.models=newModels(globalBackendRegistry,globalAnalyticsWriter);}}// resources/models/Models.tsimport{contextStorage}from'../transaction.ts';// existing ALSexportclassModels{asyncembed(input: string|string[],opts: EmbedOpts={}): Promise<Float32Array[]>{constbackend=this.#registry.resolveEmbedding(opts.model);constctx=contextStorage.getStore();// current request, if anyconstaccounting: AccountingContext={tenantId: extractTenantId(ctx?.user),// free-form string for v1app: (ctxasany)?.handlerPath,};constsignal=opts.signal??ctx?.signal;constbackendOpts: BackendOpts<EmbedOpts>={ ...opts, signal, accounting };constresult=awaitbackend.embed!(input,backendOpts);this.#recordCall({/* ... */});if(result.status==='completed')returnresult.output;thrownewError(`Backend ${backend.name} returned pending; long-running ops not yet supported`);}// generate(), generateStream() follow same pattern}
Precedent: resources/Table.ts:3484 already uses contextStorage.getStore() inside the struct prototype getter for computed-field resolvers. Same store, same pattern.
No-tenant accounting (tenant undefined; call still recorded)
Inside a job, cron, internal handler
Whatever the caller set up (often none)
Same as above
hdb_model_calls table
Imperative table({...}) declaration, mirroring existing system tables (hdb_certificate, hdb_analytics, DurableSubscriptionsSession). Type annotations follow the style at server/DurableSubscriptionsSession.ts:42-46.
Twelve fields named verbatim from Add unified model-access API (scope.models) #510's accounting section: tenant, app, model, backend, prompt_tokens, completion_tokens, embedding_tokens, gpu_ms, latency_ms, success, conversation_id, adapter.
Three additions for index / triage utility: id (primary key), method, error_code.
Timestamp is auto-tracked via Harper's record metadata ($updatedtime / $createdtime); no explicit field needed.
Writer pattern: in-memory buffer with periodic flush (model: resources/analytics/write.ts:39-106), but writes per-call rows rather than aggregating. Buffer capped at ~10s or ~1000 records, whichever comes first; configurable.
Retention: explicit periodic cleanup() mirroring analytics/write.ts:721-722 (where RAW_EXPIRATION = 1 hour, AGGREGATE_EXPIRATION = 1 year). Default for hdb_model_calls: 90 days, configurable via a new env (ANALYTICS_MODELCALL_RETENTION_DAYS). Tuned for billing windows.
TestBackend
Deterministic in-memory implementation for tests — no external dependencies, no model files to download.
LRO operation polling / getOperation / cancelOperation methods on the public facade (interface reserves ModelCallResult.pending for forward-compat; polling lands when a real LRO-capable backend ships).
// In a Resource method, with TestBackend registered as default embedding backend:classTestResourceextendsResource{asyncpost(_target,_data,_request){constvectors=awaitscope.models.embed('hello world');return{vectorLength: vectors[0].length};}}// POST to the Resource:// curl -X POST http://localhost:9926/TestResource/ -d '{}'// Expected: { vectorLength: 16 }// Verify: SELECT * FROM system.hdb_model_calls WHERE backend = 'test'// shows one row with tenant/app populated from the request, method='embed', latency_ms set, success=true.// And from app-init (outside any Resource scope, no ALS context):// scope.models.embed('init')// Expected: vector returned, hdb_model_calls row has tenant undefined.
Scope
Foundation for the model-access API in #510:
ModelBackend,Models, options types,ModelCallResult,TokenUsage).Modelsfacade exposed asscope.modelson theScopeclass — internally readscontextStorage.getStore()(the existing ALS atresources/Table.ts:3484) to pick up per-request context (tenant, signal, app).models.<embedding|generative>.<name>→ backend instance).hdb_model_callssystem table + buffered writer, declared imperatively per existing system-table convention.TestBackendfor tests.opts.conversationIdaccepted onGenerateOpts(inert until Add ConversationResource for agent memory and conversation state #511 lands; no observable behavior in this phase).No real backends ship in this phase — interface plus test fixture only. Real backends land in Phases 2 (
ollama), 3 (openai), and 8 (anthropic,bedrock).Why first
Every other phase depends on the interface, the facade, the backend registry, and the analytics writer. Sub-issues that build on this: Phase 2, 3, 4, 5, 8 (#510 sub-issues) and #612 (agent-loop orchestration). External issues that consume the interface: #511 (
opts.conversationIdactivation).API surface
Implementation pattern: ALS-backed
scope.modelsThe developer writes
scope.models.embed(text)from anywhere in their component —handleApplication(scope)init code, Resource methods (via closure), nested helpers. ALS provides per-request context when called inside a Resource scope; falls back to no-tenant accounting when called outside one.Precedent:
resources/Table.ts:3484already usescontextStorage.getStore()inside the struct prototype getter for computed-field resolvers. Same store, same pattern.Behavior across call sites:
handleApplication(scope)init codehdb_model_callstableImperative
table({...})declaration, mirroring existing system tables (hdb_certificate,hdb_analytics,DurableSubscriptionsSession). Type annotations follow the style atserver/DurableSubscriptionsSession.ts:42-46.Field-by-field source:
tenant, app, model, backend, prompt_tokens, completion_tokens, embedding_tokens, gpu_ms, latency_ms, success, conversation_id, adapter.id(primary key),method,error_code.$updatedtime/$createdtime); no explicit field needed.Writer pattern: in-memory buffer with periodic flush (model:
resources/analytics/write.ts:39-106), but writes per-call rows rather than aggregating. Buffer capped at ~10s or ~1000 records, whichever comes first; configurable.Retention: explicit periodic
cleanup()mirroringanalytics/write.ts:721-722(whereRAW_EXPIRATION = 1 hour,AGGREGATE_EXPIRATION = 1 year). Default forhdb_model_calls: 90 days, configurable via a new env (ANALYTICS_MODELCALL_RETENTION_DAYS). Tuned for billing windows.TestBackendDeterministic in-memory implementation for tests — no external dependencies, no model files to download.
Files
resources/models/types.tsresources/models/Models.tsresources/models/backendRegistry.tsresources/models/analyticsTable.tshdb_model_callsdeclaration + buffered writerresources/models/TestBackend.tscomponents/Scope.ts:36models: Modelsproperty, constructed in Scope's constructorAcceptance criteria
ModelBackend,Models,EmbedOpts,GenerateOpts,ModelCapabilities,ModelCallResult,AccountingContext,TokenUsagedefined inresources/models/types.ts.Modelsfacade exposed asscope.modelson everyScopeinstance.scope.models.embed()from inside a Resource method picks up the request's user, signal, and handlerPath via ALS.scope.models.embed()from outside a Resource scope (init code, internal jobs) proceeds withtenantId: undefinedand no error.hdb_model_callssystem table created in thesystemdatabase with the schema above.hdb_model_callswith tenant, app, model, backend, method, token counts, latency, success.TestBackendavailable for tests; produces deterministic vectors and text.opts.conversationIdaccepted onGenerateOpts; no behavior change in this phase (activation lands with Add ConversationResource for agent memory and conversation state #511).models.embedding.default.backend→ registered backend instance.AbortSignalpropagation, backend registry resolution,ModelCallResult.pendingrejection path.Out of scope
@embedschema directive (Phase 5)./v1/embeddings+/v1/chat/completions(Phase 4).toolMode: 'auto'orchestration (split to Add agent-loop orchestration /toolMode: 'auto'toscope.models#612).conversationIdactivation (wired but inert until Add ConversationResource for agent memory and conversation state #511 lands).getOperation/cancelOperationmethods on the public facade (interface reservesModelCallResult.pendingfor forward-compat; polling lands when a real LRO-capable backend ships).Stacks on
Nothing. This is the foundation.
Hard prerequisites
request.signalexposed on Resource methods) —Modelsfacade readsctx.signalfrom the ALS-bound context.Branch & PR conventions
feat/models-foundationmainCloses #<self>; references Add unified model-access API (scope.models) #510 viaTracking: #510.CONTRIBUTING.md).Smoke test
Tracking
Part of #510. Sub-issue 1 of 6.
🤖 Generated with Claude Code