zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Ecosystem integrations

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:

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