HTTP Tools from ControllersSupported
Install:
yarn add @zmdb/ai@1.0.0-beta.2 @zmdb/schema-core@1.0.0-beta.2 @zmdb/aot-validator@1.0.0-beta.2 @zmdb/web@1.0.0-beta.2. OpenAPI-derived tools and callers live in@zmdb/ai/http; no model SDK is installed by this package.
@zmdb/ai/http turns an OpenAPI document into checked-in tool specifications, TypeScript argument types, AOT-compiled validators, and request plans. Generation is build-time; the runtime imports the generated module and binds it to a caller-owned HTTP policy.
Safety boundary#
The generated caller has no authority of its own:
- model-written headers and cookies are dropped, so credentials and tenant identity remain caller-owned;
- every path, query, and body value comes from the generated validator's result;
- the base URL must exactly match a caller-supplied allowlist;
- URL dot segments and non-scalar URL parameter shapes are refused;
- requests default to a 60-second timeout and responses to a 1 MiB bound;
- generation never fetches an external
$ref.
The model chooses arguments to a known operation. It does not choose a host, credential, or transport identity.
Self-roundtrip from a zmdb HTTP contract#
Render the OpenAPI document from the same compiled HttpContractIR the service routes, then generate and check in the module:
import { writeFile } from 'node:fs/promises';
import { generateOpenApiToolsModule } from '@zmdb/ai/http';
import { compileHttpContracts } from '@zmdb/web/contract/compiler';
import { toOpenApi } from '@zmdb/web/openapi';
import { HTTP_CONTRACT } from './http-contract.js';
const compiled = compileHttpContracts([{ file: new URL('./http-contract.ts', import.meta.url), exportName: 'HTTP_CONTRACT', contract: HTTP_CONTRACT }], { session });
const document = toOpenApi(compiled.ir, {
info: { title: 'Users', version: '1.0.0' },
});
await writeFile('src/openapi-tools.ts', generateOpenApiToolsModule(document));The repository's shared-contract round-trip fixture emits this real generated shape (abridged only after the request plan):
// generated by @zmdb/ai/http — do not edit
import type { OpenApiGeneratedTool } from '@zmdb/ai/http';
import { assert } from '@zmdb/validator';
import { type MaxLength } from '@zmdb/schema/tags';
export type PostUsersArguments = {
readonly createdAt?: string;
readonly email: string & MaxLength<255>;
};
export const postUsersTool: OpenApiGeneratedTool<PostUsersArguments> = {
spec: {
name: 'post_users',
parameters: {
type: 'object',
properties: {
createdAt: {
format: 'date-time',
type: 'string',
},
email: {
maxLength: 255,
type: 'string',
},
},
required: ['email'],
},
},
request: {
method: 'POST',
path: '/users',
pathParameters: [],
queryParameters: [],
bodyParameters: ['createdAt', 'email'],
hasBody: true,
},
validate: (input: unknown): PostUsersArguments => assert<PostUsersArguments>(input),
};The normal zmdb AOT transform replaces that assert<T> with checks from the same TypeIR emitter as handwritten validators. The checked-in fixture test also compares the generated module byte-for-byte with a fresh generation, so a contract or schema change cannot leave stale tool code silently.
The spec is provider-neutral. Read JSON Schema for LLMs for provider dialect limits, and use the bounded Chat & Agents driver layer to frame it for a model.
Bind a generated tool to one exact caller-owned base URL:
import { bindOpenApiTool } from '@zmdb/ai/http';
import { openApiTools } from './openapi-tools.js';
const createIssue = bindOpenApiTool(openApiTools.create_issue, {
baseUrl: 'https://issues.example.com/v1/',
allowedBaseUrls: ['https://issues.example.com/v1/'],
headers: { authorization: `Bearer ${requireEnv('ISSUES_TOKEN')}` },
});createIssue has the { spec, validate, handler } shape the bounded chat registry expects. Validation runs before the handler. Path values are percent-encoded, query values go through URLSearchParams, and only generated body fields are serialized. Header and cookie parameters are deliberately absent from model-controlled arguments; credentials remain in the caller configuration. An unlisted base URL and URL dot segments are refused before any request can run.
Anthropic#
import type { ToolSpecFor } from '@zmdb/ai';
import { assert } from '@zmdb/validator';
interface AnthropicResponse {
content: ({ type: 'text'; text: string } | { type: 'tool_use'; name: string; input: unknown })[];
usage: { input_tokens: number; output_tokens: number };
}
export async function extract<T>(prompt: string, tool: ToolSpecFor['anthropic']) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': requireEnv('ANTHROPIC_API_KEY'),
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: 'claude-opus-5',
max_tokens: 1024,
tools: [tool],
tool_choice: { type: 'tool', name: tool.name },
messages: [{ role: 'user', content: prompt }],
}),
});
if (!res.ok) throw new Error(`anthropic ${res.status}: ${await res.text()}`);
const body = assert<AnthropicResponse>(await res.json());
const block = body.content.find(c => c.type === 'tool_use');
if (block === undefined) throw new Error('no tool call in response');
return assert<T>(block.input);
}requireEnv(name) is the three-line helper from Configuration — it throws on a missing or empty variable, so a misconfigured deployment fails at boot rather than on the first query.
The two assert calls are doing different jobs, and both are worth having. The first checks the _provider's_ response shape — an API change or an error body that came back with a 200 fails here with a field name. The second checks the _model's_ output against your type. Neither is redundant.
OpenAI#
import { toolFor } from '@zmdb/ai';
const tool = toolFor<User>('openai-strict', 'user');
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.OPENAI_API_KEY}`, 'content-type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4o',
messages: [{ role: 'user', content: prompt }],
response_format: {
type: 'json_schema',
json_schema: {
name: tool.function.name,
strict: tool.function.strict,
schema: tool.function.parameters,
},
},
}),
});toolFor adds additionalProperties: false, lists every property in required, widens optional non-nullable fields to include null, and refuses a property with no expressible type. Those choices are made from the declaration IR before the document is inlined; adding required to a generic JSON Schema after the fact would no longer know which fields were optional.
Retries#
Model APIs rate-limit and occasionally return a 529. Retry on the retryable statuses only:
async function withRetry<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
for (let i = 0; ; i++) {
try {
return await fn();
} catch (e) {
const status = e instanceof HttpError ? e.status : 0;
const retryable = status === 429 || status === 529 || status >= 500;
if (i >= attempts - 1 || !retryable) throw e;
await new Promise(r => setTimeout(r, 2 ** i * 500 + Math.random() * 200));
}
}
}Jitter matters: without it, every concurrent request retries at the same instant and you rate-limit yourself. Honour retry-after when the response carries it.
Timeouts#
fetch has no default timeout, so a hung request hangs forever:
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(60_000) });Sixty seconds is not generous for a long completion — size it to your max_tokens, not to a habit from ordinary HTTP calls.
Recording usage#
Store it, from the beginning. Cost attribution after the fact is impossible without it:
await usageRepo.create({
userId,
model: 'claude-opus-5',
inputTokens: body.usage.input_tokens,
outputTokens: body.usage.output_tokens,
});Then repo.aggregate() answers "which endpoint costs the most" from real numbers. See Aggregations.
Never log the key#
console.log({ url, status: res.status }); // fine
console.log({ headers: init.headers }); // logs your API key---
See also: Chat & Agents · Model Context Protocol · JSON Schema for LLMs · Logging