Bindings, validation, deadlines, and real receipts
The policy implementation follows.
ts
export class ModelCalls<
Input = unknown,
Output = unknown,
const Bindings extends readonly ModelBinding<any, any>[] = readonly ModelBinding<Input, Output>[],
> {
private sequence = 0;
private readonly bindings = new ProtocolRegistry<ModelBinding<any, any>>();
/**
* @param bindings - Bindings to register; each needs a non-blank `identity` and an `invoke`.
* @throws TypeError When a binding is malformed or two bindings share an id.
*/
constructor(bindings: Bindings) {
for (const binding of bindings) {
if (!binding.identity?.trim() || typeof binding.invoke !== 'function')
throw new TypeError('Invalid model binding');
this.bindings.set(
binding.id,
Object.freeze({
id: binding.id,
identity: binding.identity,
invoke: binding.invoke.bind(binding),
assertInput: binding.assertInput?.bind(binding),
assertOutput: binding.assertOutput?.bind(binding),
}),
);
}
}
/** Registered bindings in declaration order, with methods already bound to their owner. */
get definitions(): readonly RegisteredBinding<Bindings[number]>[] {
return Object.freeze([...this.bindings.values()]) as readonly RegisteredBinding<
Bindings[number]
>[];
}
/** Registered binding ids in declaration order. */
ids(): readonly string[] {
return this.bindings.ids();
}
/** Whether a binding id is registered. */
has(id: string): boolean {
return this.bindings.has(id);
}
/** Registered binding for `id`, or `undefined` when it is absent. */
get(id: string): RegisteredBinding<Bindings[number]> | undefined {
return this.bindings.get(id) as RegisteredBinding<Bindings[number]> | undefined;
}
/**
* Registered binding for `id`.
*
* @throws TypeError When the id is not registered.
*/
require(id: string): RegisteredBinding<Bindings[number]> {
return this.bindings.require(id) as RegisteredBinding<Bindings[number]>;
}
/** Iterate the registered bindings. */
values(): IterableIterator<RegisteredBinding<Bindings[number]>> {
return this.bindings.values() as IterableIterator<RegisteredBinding<Bindings[number]>>;
}
/** Iterate `[id, binding]` pairs. */
entries(): IterableIterator<[string, RegisteredBinding<Bindings[number]>]> {
return this.bindings.entries() as IterableIterator<
[string, RegisteredBinding<Bindings[number]>]
>;
}
/**
* Implementation identity of one binding.
*
* @throws TypeError When the id is not registered.
*/
identity(id: string): string {
return this.require(id).identity;
}
/**
* Invoke one registered binding under the caller's signal, timeout and attempt budget.
*
* Order matters: the binding id is resolved, input is asserted, the budget take happens
* before the provider call, and the output assertion runs before a result is reported. Any
* failure emits a diagnostic and rethrows unchanged, so callers see the provider's own error.
*
* @param request - Binding id, input and per-call timeout.
* @param context - Execution context; `signal` is combined with a timeout signal.
* @returns The provider output wrapped with invocation metadata.
* @throws TypeError For a malformed request, unknown id, missing signal/context, invalid
* timeout or failed input/output assertion; `BudgetError` when the attempt budget refuses;
* the provider's own error otherwise, including abort and timeout.
*/
invoke<const Id extends string>(
request: ModelInvocationRequest<InputFor<Bindings, Id>> & { readonly modelId: Id },
context: ModelInvocationContext,
): Promise<ModelResult<OutputFor<Bindings, Id>>>;
async invoke(
request: ModelInvocationRequest<unknown>,
context: ModelInvocationContext,
): Promise<ModelResult<unknown>> {
if (!request || typeof request !== 'object' || !('input' in request))
throw new TypeError('Use one model request and execution context');
const { modelId: id, input, timeoutMs } = request;
if (
!context?.signal ||
typeof id !== 'string' ||
!id.trim() ||
!Number.isSafeInteger(timeoutMs) ||
timeoutMs < 1 ||
// 2147483647 is the largest delay `setTimeout`/`AbortSignal.timeout` accept reliably.
timeoutMs > 2147483647
)
throw new TypeError('A modelId, signal and positive supported timeoutMs are required');
const sourceBudget = context.attemptBudget;
const take = sourceBudget?.take.bind(sourceBudget);
const callId = invocationId(),
startedAtMs = Date.now();
const diagnostics = context.diagnostics?.child(callId, {
invocationId: callId,
modelId: id,
nodeRole: 'invocation',
definitionKind: 'model-binding',
definitionId: id,
nodeId: id,
nodeName: id,
nodeKind: 'model-binding',
});
const signal = combineSignals(context.signal, AbortSignal.timeout(timeoutMs));
let phase: 'input' | 'invoke' | 'output' = 'input';
try {
const binding = this.bindings.require(id);
signal.throwIfAborted();
assertion(binding.assertInput?.(input), 'Model input assertions must be synchronous');
signal.throwIfAborted();
if (take) {
const accepted = synchronous(take(), 'Model attempt budget must be synchronous');
breakWhile(typeof accepted !== 'boolean', 'Model attempt budget must return boolean');
breakWhile(!accepted, () => new BudgetError('External model attempt budget exhausted'));
}
const callSequence = ++this.sequence;
phase = 'invoke';
diagnostics?.emit('model.binding', () => ({
modelId: id,
invocationId: callId,
status: 'running',
phase,
}));
// Hand off checked inputs before yielding. Arbitrary App inputs may be
// class instances or services, so Core must not silently clone them.
signal.throwIfAborted();
const output = await awaitWithSignal(
binding.invoke(input, { ...context, signal, diagnostics, attemptBudget: sourceBudget }),
signal,
);
signal.throwIfAborted();
phase = 'output';
assertion(binding.assertOutput?.(output), 'Model output assertions must be synchronous');
signal.throwIfAborted();
diagnostics?.emit('model.binding', () => ({
modelId: id,
invocationId: callId,
status: 'success',
phase,
durationMs: Date.now() - startedAtMs,
}));
return invocationResult(output, {
...diagnostics?.identity,
invocationId: callId,
modelId: id,
startedAtMs,
durationMs: Date.now() - startedAtMs,
callSequence,
status: 'success' as const,
});
} catch (error) {
diagnostics?.emit('model.binding', () => ({
modelId: id,
invocationId: callId,
status: signal.aborted ? 'cancelled' : 'error',
phase,
durationMs: Date.now() - startedAtMs,
errorCode: getErrorInfo(error).code,
}));
throw error;
}
}
}