@zmdb/ai 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +10 -0
  2. package/README.md +56 -0
  3. package/dist/chat/index.d.ts +81 -0
  4. package/dist/chat/index.d.ts.map +1 -0
  5. package/dist/chat/index.js +86 -0
  6. package/dist/chat/index.js.map +1 -0
  7. package/dist/compiler.d.ts +3 -0
  8. package/dist/compiler.d.ts.map +1 -0
  9. package/dist/compiler.js +2 -0
  10. package/dist/compiler.js.map +1 -0
  11. package/dist/http/caller.d.ts +3 -0
  12. package/dist/http/caller.d.ts.map +1 -0
  13. package/dist/http/caller.js +128 -0
  14. package/dist/http/caller.js.map +1 -0
  15. package/dist/http/generate.d.ts +7 -0
  16. package/dist/http/generate.d.ts.map +1 -0
  17. package/dist/http/generate.js +205 -0
  18. package/dist/http/generate.js.map +1 -0
  19. package/dist/http/index.d.ts +5 -0
  20. package/dist/http/index.d.ts.map +1 -0
  21. package/dist/http/index.js +6 -0
  22. package/dist/http/index.js.map +1 -0
  23. package/dist/http/parse.d.ts +11 -0
  24. package/dist/http/parse.d.ts.map +1 -0
  25. package/dist/http/parse.js +398 -0
  26. package/dist/http/parse.js.map +1 -0
  27. package/dist/http/types.d.ts +59 -0
  28. package/dist/http/types.d.ts.map +1 -0
  29. package/dist/http/types.js +20 -0
  30. package/dist/http/types.js.map +1 -0
  31. package/dist/index.d.ts +12 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +33 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/providers.d.ts +85 -0
  36. package/dist/providers.d.ts.map +1 -0
  37. package/dist/providers.js +252 -0
  38. package/dist/providers.js.map +1 -0
  39. package/dist/tool-runtime.d.ts +36 -0
  40. package/dist/tool-runtime.d.ts.map +1 -0
  41. package/dist/tool-runtime.js +55 -0
  42. package/dist/tool-runtime.js.map +1 -0
  43. package/package.json +66 -0
  44. package/src/chat/index.ts +202 -0
  45. package/src/compiler.ts +2 -0
  46. package/src/http/caller.ts +132 -0
  47. package/src/http/generate.ts +218 -0
  48. package/src/http/index.ts +16 -0
  49. package/src/http/parse.ts +666 -0
  50. package/src/http/types.ts +83 -0
  51. package/src/index.ts +40 -0
  52. package/src/providers.ts +443 -0
  53. package/src/tool-runtime.ts +91 -0
@@ -0,0 +1,202 @@
1
+ import type { ToolSpec } from '../index.js';
2
+ import { invokeTool } from '../tool-runtime.js';
3
+
4
+ export type ChatMessage =
5
+ | { readonly role: 'system'; readonly content: string }
6
+ | { readonly role: 'user'; readonly content: string }
7
+ | {
8
+ readonly role: 'assistant';
9
+ readonly content: string;
10
+ readonly toolCalls?: readonly ToolCall[];
11
+ readonly provider?: readonly ProviderPassthrough[];
12
+ }
13
+ | { readonly role: 'tool'; readonly callId: string; readonly content: string; readonly isError?: boolean };
14
+
15
+ export interface ToolCall {
16
+ readonly id: string;
17
+ readonly name: string;
18
+ readonly args: unknown;
19
+ }
20
+
21
+ export interface ProviderPassthrough {
22
+ readonly kind: string;
23
+ readonly raw: unknown;
24
+ }
25
+
26
+ export interface ChatDriver {
27
+ next(messages: readonly ChatMessage[], tools: readonly ToolSpec[]): Promise<ChatMessage>;
28
+ }
29
+
30
+ type ToolHandler<T> = {
31
+ bivarianceHack(input: T, identity?: unknown): unknown | PromiseLike<unknown>;
32
+ }['bivarianceHack'];
33
+
34
+ export interface ToolEntry<T> {
35
+ readonly spec: ToolSpec;
36
+ readonly validate: (args: unknown) => T;
37
+ readonly handler: ToolHandler<T>;
38
+ readonly effectful?: boolean;
39
+ }
40
+
41
+ export type ToolRegistry = Readonly<Record<string, ToolEntry<unknown>>>;
42
+
43
+ type ToolInputs = Readonly<Record<string, unknown>>;
44
+
45
+ interface LinkedToolEntry<T> {
46
+ readonly spec: ToolSpec;
47
+ readonly validate: (args: unknown) => T;
48
+ readonly handler: (input: T, identity?: unknown) => unknown | PromiseLike<unknown>;
49
+ readonly effectful?: boolean;
50
+ }
51
+
52
+ type LinkedRegistry<I extends ToolInputs> = {
53
+ readonly [K in keyof I]: LinkedToolEntry<I[K]>;
54
+ };
55
+
56
+ export function defineTools<const I extends ToolInputs, const R extends LinkedRegistry<I>>(
57
+ tools: R & LinkedRegistry<I>,
58
+ ): R {
59
+ return tools;
60
+ }
61
+
62
+ export type HasEffectful<R> = {
63
+ [K in keyof R]: R[K] extends { readonly effectful: false } ? never : K;
64
+ }[keyof R] extends never
65
+ ? false
66
+ : true;
67
+
68
+ export interface RunOptions {
69
+ readonly maxTurns: number;
70
+ readonly maxToolCallsPerTurn?: number;
71
+ readonly approve?: (call: ToolCall) => Promise<boolean>;
72
+ }
73
+
74
+ export type RunOptionsFor<R extends Readonly<Record<string, unknown>>> =
75
+ HasEffectful<R> extends true ? RunOptions & { readonly approve: (call: ToolCall) => Promise<boolean> } : RunOptions;
76
+
77
+ export interface RunResult {
78
+ readonly messages: readonly ChatMessage[];
79
+ readonly stop: 'complete' | 'max-turns' | 'max-tool-calls';
80
+ readonly turns: number;
81
+ readonly toolCalls: number;
82
+ readonly budget: number;
83
+ readonly declined: readonly ToolCall[];
84
+ readonly errors: readonly {
85
+ readonly callId: string;
86
+ readonly name: string;
87
+ readonly errorId: string;
88
+ readonly error: unknown;
89
+ }[];
90
+ }
91
+
92
+ interface RunState {
93
+ readonly messages: ChatMessage[];
94
+ readonly declined: ToolCall[];
95
+ readonly errors: {
96
+ readonly callId: string;
97
+ readonly name: string;
98
+ readonly errorId: string;
99
+ readonly error: unknown;
100
+ }[];
101
+ turns: number;
102
+ toolCalls: number;
103
+ }
104
+
105
+ const DEFAULT_MAX_TOOL_CALLS_PER_TURN = 8;
106
+
107
+ const toolErrorId = (): string =>
108
+ [...globalThis.crypto.getRandomValues(new Uint8Array(4))].map(byte => byte.toString(16).padStart(2, '0')).join('');
109
+
110
+ const toolResult = (callId: string, content: string, isError?: boolean): ChatMessage =>
111
+ isError === undefined ? { role: 'tool', callId, content } : { role: 'tool', callId, content, isError };
112
+
113
+ const finish = (state: RunState, stop: RunResult['stop'], budget: number): RunResult => ({
114
+ messages: state.messages,
115
+ stop,
116
+ turns: state.turns,
117
+ toolCalls: state.toolCalls,
118
+ budget,
119
+ declined: state.declined,
120
+ errors: state.errors,
121
+ });
122
+
123
+ export async function run<const I extends ToolInputs, R extends LinkedRegistry<I>>(
124
+ driver: ChatDriver,
125
+ messages: readonly ChatMessage[],
126
+ tools: R & LinkedRegistry<I>,
127
+ opts: RunOptionsFor<R>,
128
+ ): Promise<RunResult> {
129
+ if (!Number.isSafeInteger(opts.maxTurns) || opts.maxTurns <= 0) {
130
+ throw new RangeError('maxTurns must be a positive safe integer');
131
+ }
132
+ const maxToolCallsPerTurn = opts.maxToolCallsPerTurn ?? DEFAULT_MAX_TOOL_CALLS_PER_TURN;
133
+ if (!Number.isSafeInteger(maxToolCallsPerTurn) || maxToolCallsPerTurn <= 0) {
134
+ throw new RangeError('maxToolCallsPerTurn must be a positive safe integer');
135
+ }
136
+
137
+ const entries = Object.entries(tools);
138
+ const firstEffectful = entries.find(([, entry]) => entry.effectful !== false);
139
+ if (firstEffectful !== undefined && opts.approve === undefined) {
140
+ throw new Error(`approve is required for effectful tool ${firstEffectful[0]}`);
141
+ }
142
+
143
+ const budget = opts.maxTurns * maxToolCallsPerTurn;
144
+ const state: RunState = {
145
+ messages: [...messages],
146
+ declined: [],
147
+ errors: [],
148
+ turns: 0,
149
+ toolCalls: 0,
150
+ };
151
+ const specs = entries.map(([, entry]) => entry.spec);
152
+
153
+ while (state.turns < opts.maxTurns) {
154
+ const answer = await driver.next(state.messages, specs);
155
+ state.messages.push(answer);
156
+ state.turns += 1;
157
+
158
+ const calls = answer.role === 'assistant' ? (answer.toolCalls ?? []) : [];
159
+ if (calls.length === 0) return finish(state, 'complete', budget);
160
+ if (calls.length > maxToolCallsPerTurn) return finish(state, 'max-tool-calls', budget);
161
+
162
+ for (const call of calls) {
163
+ state.toolCalls += 1;
164
+ const entry = Object.hasOwn(tools, call.name) ? tools[call.name] : undefined;
165
+ if (entry === undefined) {
166
+ state.messages.push(toolResult(call.id, `unknown tool ${call.name}`, true));
167
+ continue;
168
+ }
169
+
170
+ if (entry.effectful !== false) {
171
+ const approved = await opts.approve?.(call);
172
+ if (approved !== true) {
173
+ state.declined.push(call);
174
+ state.messages.push(toolResult(call.id, 'declined by the operator', true));
175
+ continue;
176
+ }
177
+ }
178
+
179
+ const invocation = await invokeTool(entry, call.args);
180
+ if (invocation.kind === 'validation-error') {
181
+ const { content } = invocation;
182
+ if (content !== undefined) {
183
+ state.messages.push(toolResult(call.id, content, true));
184
+ continue;
185
+ }
186
+ const id = toolErrorId();
187
+ state.errors.push({ callId: call.id, name: call.name, errorId: id, error: invocation.error });
188
+ state.messages.push(toolResult(call.id, `tool ${call.name} failed (${id})`, true));
189
+ continue;
190
+ }
191
+ if (invocation.kind === 'handler-error') {
192
+ const id = toolErrorId();
193
+ state.errors.push({ callId: call.id, name: call.name, errorId: id, error: invocation.error });
194
+ state.messages.push(toolResult(call.id, `tool ${call.name} failed (${id})`, true));
195
+ continue;
196
+ }
197
+ state.messages.push(toolResult(call.id, invocation.content));
198
+ }
199
+ }
200
+
201
+ return finish(state, 'max-turns', budget);
202
+ }
@@ -0,0 +1,2 @@
1
+ export { ToolSpecRefusalError, toolSchemaForProvider } from './providers.js';
2
+ export type { ToolSpecRefusal } from './providers.js';
@@ -0,0 +1,132 @@
1
+ // Runtime binding for provider-neutral OpenAPI tools owned by @zmdb/ai.
2
+ import {
3
+ OpenApiHttpError,
4
+ type BoundOpenApiTool,
5
+ type OpenApiCallerOptions,
6
+ type OpenApiGeneratedTool,
7
+ type OpenApiToolRequest,
8
+ } from './types.js';
9
+
10
+ const DEFAULT_TIMEOUT_MS = 60_000;
11
+ const DEFAULT_MAX_RESPONSE_BYTES = 1_048_576;
12
+
13
+ function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
14
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
15
+ }
16
+
17
+ function positive(name: string, value: number): void {
18
+ if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
19
+ }
20
+
21
+ function canonicalBase(value: string): URL {
22
+ const url = new URL(value);
23
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
24
+ throw new TypeError('OpenAPI tool baseUrl must use http or https');
25
+ }
26
+ if (url.username !== '' || url.password !== '') {
27
+ throw new TypeError('OpenAPI tool baseUrl must not contain credentials');
28
+ }
29
+ if (url.search !== '' || url.hash !== '') {
30
+ throw new TypeError('OpenAPI tool baseUrl must not contain a query or fragment');
31
+ }
32
+ if (!url.pathname.endsWith('/')) url.pathname += '/';
33
+ return url;
34
+ }
35
+
36
+ function allowlistedBase(baseUrl: string, allowlist: readonly string[]): URL {
37
+ const base = canonicalBase(baseUrl);
38
+ const allowed = allowlist.some(candidate => canonicalBase(candidate).href === base.href);
39
+ if (!allowed) throw new Error(`OpenAPI tool base URL is not allowlisted: ${base.href}`);
40
+ return base;
41
+ }
42
+
43
+ function scalar(value: unknown, name: string): string {
44
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return String(value);
45
+ if (value === null) return 'null';
46
+ throw new TypeError(`validated OpenAPI argument ${name} is not a URL scalar`);
47
+ }
48
+
49
+ function pathSegment(value: unknown, name: string): string {
50
+ const segment = scalar(value, name);
51
+ if (segment === '.' || segment === '..') {
52
+ throw new RangeError(`validated OpenAPI argument ${name} is a URL dot segment`);
53
+ }
54
+ return encodeURIComponent(segment);
55
+ }
56
+
57
+ function requestUrl(base: URL, request: OpenApiToolRequest, input: Readonly<Record<string, unknown>>): URL {
58
+ let pathname = request.path;
59
+ for (const name of request.pathParameters) {
60
+ pathname = pathname.replaceAll(`{${name}}`, pathSegment(input[name], name));
61
+ }
62
+ if (/\{[^{}]+\}/.test(pathname)) throw new Error(`OpenAPI tool path still contains a placeholder: ${pathname}`);
63
+
64
+ const url = new URL(pathname.replace(/^\/+/, ''), base);
65
+ for (const name of request.queryParameters) {
66
+ const value = input[name];
67
+ if (value === undefined) continue;
68
+ if (Array.isArray(value)) {
69
+ for (const item of value) url.searchParams.append(name, scalar(item, name));
70
+ } else {
71
+ url.searchParams.append(name, scalar(value, name));
72
+ }
73
+ }
74
+ return url;
75
+ }
76
+
77
+ function requestBody(request: OpenApiToolRequest, input: Readonly<Record<string, unknown>>): string | undefined {
78
+ if (!request.hasBody) return undefined;
79
+ const body: Record<string, unknown> = {};
80
+ for (const name of request.bodyParameters) {
81
+ if (input[name] !== undefined) body[name] = input[name];
82
+ }
83
+ return JSON.stringify(body);
84
+ }
85
+
86
+ async function responseBody(response: Response, maximum: number): Promise<string> {
87
+ const announced = response.headers.get('content-length');
88
+ if (announced !== null && Number(announced) > maximum) {
89
+ throw new RangeError(`OpenAPI tool response exceeds ${maximum} bytes`);
90
+ }
91
+ const body = await response.text();
92
+ if (new TextEncoder().encode(body).byteLength > maximum) {
93
+ throw new RangeError(`OpenAPI tool response exceeds ${maximum} bytes`);
94
+ }
95
+ return body;
96
+ }
97
+
98
+ function parseResponse(response: Response, body: string): unknown {
99
+ if (body === '') return undefined;
100
+ const contentType = response.headers.get('content-type')?.toLowerCase() ?? '';
101
+ return contentType.includes('json') ? JSON.parse(body) : body;
102
+ }
103
+
104
+ export function bindOpenApiTool<T>(tool: OpenApiGeneratedTool<T>, options: OpenApiCallerOptions): BoundOpenApiTool<T> {
105
+ const base = allowlistedBase(options.baseUrl, options.allowedBaseUrls);
106
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
107
+ const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
108
+ positive('timeoutMs', timeoutMs);
109
+ positive('maxResponseBytes', maxResponseBytes);
110
+ const fetch = options.fetch ?? globalThis.fetch;
111
+
112
+ return {
113
+ spec: tool.spec,
114
+ validate: tool.validate,
115
+ async handler(input: T): Promise<unknown> {
116
+ if (!isRecord(input)) throw new TypeError('generated OpenAPI validator returned a non-object');
117
+ const url = requestUrl(base, tool.request, input);
118
+ const body = requestBody(tool.request, input);
119
+ const headers = new Headers(options.headers);
120
+ if (body !== undefined && !headers.has('content-type')) headers.set('content-type', 'application/json');
121
+ const response = await fetch(url, {
122
+ method: tool.request.method,
123
+ headers,
124
+ ...(body === undefined ? {} : { body }),
125
+ signal: AbortSignal.timeout(timeoutMs),
126
+ });
127
+ const text = await responseBody(response, maxResponseBytes);
128
+ if (!response.ok) throw new OpenApiHttpError(response.status, text);
129
+ return parseResponse(response, text);
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,218 @@
1
+ // Checked-in source generation for the public @zmdb/ai/http boundary.
2
+ import { compileOpenApiTools, type CompiledOpenApiTool } from './parse.js';
3
+ import type { OpenApiToolsOptions } from './types.js';
4
+
5
+ type ConstraintTag = 'Max' | 'MaxLength' | 'Min' | 'MinLength' | 'Pattern';
6
+
7
+ interface RenderContext {
8
+ readonly tags: Set<ConstraintTag>;
9
+ readonly path: string;
10
+ }
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function stringLiteral(value: string): string {
17
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'").replaceAll('\n', '\\n').replaceAll('\r', '\\r')}'`;
18
+ }
19
+
20
+ function literal(value: unknown): string {
21
+ if (typeof value === 'string') return stringLiteral(value);
22
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null) return String(value);
23
+ throw new TypeError(`OpenAPI enum contains an unsupported literal at generation: ${String(value)}`);
24
+ }
25
+
26
+ function tag(context: RenderContext, name: ConstraintTag, value: unknown): string | undefined {
27
+ if (typeof value !== (name === 'Pattern' ? 'string' : 'number')) return undefined;
28
+ context.tags.add(name);
29
+ return `${name}<${literal(value)}>`;
30
+ }
31
+
32
+ function constrained(base: string, schema: Record<string, unknown>, context: RenderContext): string {
33
+ const tags = [
34
+ tag(context, 'Min', schema['minimum']),
35
+ tag(context, 'Max', schema['maximum']),
36
+ tag(context, 'MinLength', schema['minLength']),
37
+ tag(context, 'MaxLength', schema['maxLength']),
38
+ tag(context, 'Pattern', schema['pattern']),
39
+ ].filter(value => value !== undefined);
40
+ return tags.length === 0 ? base : `${base} & ${tags.join(' & ')}`;
41
+ }
42
+
43
+ function propertyName(name: string): string {
44
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : stringLiteral(name);
45
+ }
46
+
47
+ function requiredNames(schema: Record<string, unknown>): ReadonlySet<string> {
48
+ const value = schema['required'];
49
+ return new Set(Array.isArray(value) ? value.filter(name => typeof name === 'string') : []);
50
+ }
51
+
52
+ function objectType(schema: Record<string, unknown>, context: RenderContext): string {
53
+ const properties = schema['properties'];
54
+ if (!isRecord(properties)) return 'Readonly<Record<never, never>>';
55
+ const required = requiredNames(schema);
56
+ const lines = Object.keys(properties)
57
+ .toSorted()
58
+ .map(name => {
59
+ const child = properties[name];
60
+ if (!isRecord(child)) throw new TypeError(`OpenAPI property ${context.path}.${name} is not a schema`);
61
+ const optional = required.has(name) ? '' : '?';
62
+ return ` readonly ${propertyName(name)}${optional}: ${schemaType(child, { ...context, path: `${context.path}.${name}` })};`;
63
+ });
64
+ return lines.length === 0 ? 'Readonly<Record<never, never>>' : `{\n${lines.join('\n')}\n}`;
65
+ }
66
+
67
+ function union(schema: Record<string, unknown>, key: 'anyOf' | 'oneOf', context: RenderContext): string | undefined {
68
+ const value = schema[key];
69
+ if (!Array.isArray(value)) return undefined;
70
+ return value
71
+ .map((member, index) => {
72
+ if (!isRecord(member)) throw new TypeError(`OpenAPI ${key} member ${index} at ${context.path} is not a schema`);
73
+ return schemaType(member, { ...context, path: `${context.path}.${key}[${index}]` });
74
+ })
75
+ .join(' | ');
76
+ }
77
+
78
+ function schemaType(schema: Record<string, unknown>, context: RenderContext): string {
79
+ const oneOf = union(schema, 'oneOf', context);
80
+ if (oneOf !== undefined) return oneOf;
81
+ const anyOf = union(schema, 'anyOf', context);
82
+ if (anyOf !== undefined) return anyOf;
83
+
84
+ const values = schema['enum'];
85
+ if (Array.isArray(values) && values.length > 0) return values.map(literal).join(' | ');
86
+
87
+ const type = schema['type'];
88
+ if (Array.isArray(type)) {
89
+ return type
90
+ .map(member => schemaType({ ...schema, type: member }, context))
91
+ .filter((member, index, all) => all.indexOf(member) === index)
92
+ .join(' | ');
93
+ }
94
+
95
+ let result: string;
96
+ switch (type) {
97
+ case 'string':
98
+ result = constrained('string', schema, context);
99
+ break;
100
+ case 'integer':
101
+ case 'number':
102
+ result = constrained('number', schema, context);
103
+ break;
104
+ case 'boolean':
105
+ result = 'boolean';
106
+ break;
107
+ case 'null':
108
+ result = 'null';
109
+ break;
110
+ case 'array': {
111
+ const items = schema['items'];
112
+ if (!isRecord(items)) throw new TypeError(`OpenAPI array ${context.path} has no item schema`);
113
+ result = `readonly (${schemaType(items, { ...context, path: `${context.path}[]` })})[]`;
114
+ break;
115
+ }
116
+ case 'object':
117
+ result = objectType(schema, context);
118
+ break;
119
+ default:
120
+ result = isRecord(schema['properties']) ? objectType(schema, context) : 'unknown';
121
+ }
122
+ return schema['nullable'] === true && result !== 'null' ? `${result} | null` : result;
123
+ }
124
+
125
+ function typeName(operationId: string, used: Set<string>): string {
126
+ const words = operationId.split(/[^A-Za-z0-9]+/).filter(Boolean);
127
+ const base = `${words.map(word => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`).join('') || 'Tool'}Arguments`;
128
+ let candidate = /^[A-Za-z_$]/.test(base) ? base : `Tool${base}`;
129
+ let suffix = 2;
130
+ while (used.has(candidate)) {
131
+ candidate = `${base}${suffix}`;
132
+ suffix += 1;
133
+ }
134
+ used.add(candidate);
135
+ return candidate;
136
+ }
137
+
138
+ function variableName(type: string): string {
139
+ return `${type[0]?.toLowerCase() ?? 't'}${type.slice(1).replace(/Arguments$/, 'Tool')}`;
140
+ }
141
+
142
+ function renderValue(value: unknown, depth = 0): string {
143
+ if (typeof value === 'string') return stringLiteral(value);
144
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null) return String(value);
145
+ if (Array.isArray(value)) {
146
+ if (value.length === 0) return '[]';
147
+ if (value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))) {
148
+ return `[${value.map(item => renderValue(item, depth + 1)).join(', ')}]`;
149
+ }
150
+ const indent = ' '.repeat(depth + 1);
151
+ return `[\n${value.map(item => `${indent}${renderValue(item, depth + 1)},`).join('\n')}\n${' '.repeat(depth)}]`;
152
+ }
153
+ if (isRecord(value)) {
154
+ const entries = Object.entries(value);
155
+ if (entries.length === 0) return '{}';
156
+ const indent = ' '.repeat(depth + 1);
157
+ return `{\n${entries
158
+ .map(([key, item]) => `${indent}${propertyName(key)}: ${renderValue(item, depth + 1)},`)
159
+ .join('\n')}\n${' '.repeat(depth)}}`;
160
+ }
161
+ throw new TypeError(`cannot render generated OpenAPI value: ${String(value)}`);
162
+ }
163
+
164
+ function renderTool(tool: CompiledOpenApiTool, type: string, variable: string, tags: Set<ConstraintTag>): string {
165
+ const context: RenderContext = { tags, path: tool.spec.name };
166
+ const schema = {
167
+ type: 'object',
168
+ properties: tool.argumentSchemas,
169
+ required: tool.required,
170
+ };
171
+ const args = schemaType(schema, context);
172
+ const validationType = Object.keys(tool.argumentSchemas).length === 0 ? 'OpenApiNoArguments' : type;
173
+ return `export type ${type} = ${args};
174
+
175
+ export const ${variable}: OpenApiGeneratedTool<${type}> = {
176
+ spec: ${renderValue(tool.spec, 1)},
177
+ request: ${renderValue(tool.request, 1)},
178
+ validate: (input: unknown): ${type} => assert<${validationType}>(input),
179
+ };`;
180
+ }
181
+
182
+ /**
183
+ * Render a checked-in TypeScript module. The generated `assert<T>` calls are
184
+ * deliberately left for `@zmdb/validator`'s existing build transform.
185
+ */
186
+ export function generateOpenApiToolsModule(document: unknown, options: OpenApiToolsOptions = {}): string {
187
+ const tools = compileOpenApiTools(document, options);
188
+ const tags = new Set<ConstraintTag>();
189
+ const names = new Set<string>();
190
+ const rendered = tools.map(tool => {
191
+ const type = typeName(tool.spec.name, names);
192
+ return { tool, type, variable: variableName(type) };
193
+ });
194
+ const bodies = rendered.map(entry => renderTool(entry.tool, entry.type, entry.variable, tags));
195
+ const emptyArgumentsType = rendered.some(entry => Object.keys(entry.tool.argumentSchemas).length === 0)
196
+ ? `type OpenApiNoArguments = {
197
+ readonly __openApiNoArguments?: string;
198
+ };
199
+
200
+ `
201
+ : '';
202
+ const tagImport =
203
+ tags.size === 0 ? '' : `import type { ${[...tags].toSorted().join(', ')} } from '@zmdb/schema/tags';\n`;
204
+ const registry =
205
+ rendered.length === 0
206
+ ? 'export const openApiTools = {};\n'
207
+ : `export const openApiTools = {\n${rendered
208
+ .map(entry => ` ${propertyName(entry.tool.spec.name)}: ${entry.variable},`)
209
+ .join('\n')}\n};\n`;
210
+
211
+ return `// generated by @zmdb/ai/http — do not edit
212
+ import type { OpenApiGeneratedTool } from '@zmdb/ai/http';
213
+ ${tagImport}import { assert } from '@zmdb/validator';
214
+
215
+ ${emptyArgumentsType}${bodies.join('\n\n')}
216
+
217
+ ${registry}`;
218
+ }
@@ -0,0 +1,16 @@
1
+ // Public @zmdb/ai/http surface.
2
+ export { bindOpenApiTool } from './caller.js';
3
+ export { generateOpenApiToolsModule } from './generate.js';
4
+ export { toolsFromOpenApi } from './parse.js';
5
+ export {
6
+ OpenApiHttpError,
7
+ ToolSpecRefusalError,
8
+ type BoundOpenApiTool,
9
+ type OpenApiCallerOptions,
10
+ type OpenApiGeneratedTool,
11
+ type OpenApiOperationIdentity,
12
+ type OpenApiToolRequest,
13
+ type OpenApiToolsOptions,
14
+ type ToolProvider,
15
+ type ToolSpecRefusal,
16
+ } from './types.js';