@zenera/gym 0.0.0-stage → 1.1.38
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.
- package/LICENSE +21 -0
- package/README.md +200 -2
- package/dist/box.d.ts +66 -0
- package/dist/box.js +259 -0
- package/dist/cache.d.ts +87 -0
- package/dist/cache.js +275 -0
- package/dist/command.d.ts +3 -0
- package/dist/command.js +414 -0
- package/dist/envelope.d.ts +13 -0
- package/dist/envelope.js +9 -0
- package/dist/generate.d.ts +57 -0
- package/dist/generate.js +236 -0
- package/dist/image.d.ts +25 -0
- package/dist/image.js +81 -0
- package/dist/logger.d.ts +89 -0
- package/dist/logger.js +321 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.js +363 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -0
- package/dist/paging.d.ts +79 -0
- package/dist/paging.js +518 -0
- package/dist/probe.d.ts +12 -0
- package/dist/probe.js +307 -0
- package/dist/prompt.d.ts +25 -0
- package/dist/prompt.js +188 -0
- package/dist/router.d.ts +20 -0
- package/dist/router.js +101 -0
- package/dist/schema.d.ts +21 -0
- package/dist/schema.js +296 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +626 -0
- package/dist/setup.d.ts +53 -0
- package/dist/setup.js +123 -0
- package/dist/spec.d.ts +53 -0
- package/dist/spec.js +303 -0
- package/dist/validate.d.ts +21 -0
- package/dist/validate.js +83 -0
- package/package.json +53 -5
package/dist/probe.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { properties } from "./schema.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Probes
|
|
4
|
+
//
|
|
5
|
+
// A generator is judged before it is trusted, and it is judged on inputs made
|
|
6
|
+
// up here rather than on traffic. That is not only about cost: a probe built
|
|
7
|
+
// from a request body would put whatever a caller sent into the next prompt,
|
|
8
|
+
// and a mock server is exactly the kind of thing people point at with real
|
|
9
|
+
// payloads by accident.
|
|
10
|
+
//
|
|
11
|
+
// Two probes, deliberately. One ordinary, one at the edges of whatever the
|
|
12
|
+
// schema allows — a generator that hard-codes the first probe's id passes once
|
|
13
|
+
// and fails the second, which is the mistake this catches.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/**
|
|
16
|
+
* How many items a probe asks a paged operation for.
|
|
17
|
+
*
|
|
18
|
+
* Not the schema's maximum. `bounded` below answers an unbounded want with
|
|
19
|
+
* `maximum - 1`, so a `page_size` declared `maximum: 1000` used to make every
|
|
20
|
+
* probe ask for 999 fabricated objects — minutes of Faker inside a container
|
|
21
|
+
* with a 30s budget, reported as "the file took longer than 30s" and blamed on
|
|
22
|
+
* a generator that was doing exactly what it was told. A page is judged on its
|
|
23
|
+
* shape, and three rows show it.
|
|
24
|
+
*/
|
|
25
|
+
const PROBE_PAGE_SIZE = 3;
|
|
26
|
+
export function probesFor(operation) {
|
|
27
|
+
const size = operation.paging?.size;
|
|
28
|
+
return [0, 1].map((variant) => ({
|
|
29
|
+
operationId: operation.operationId,
|
|
30
|
+
method: operation.method,
|
|
31
|
+
path: operation.path,
|
|
32
|
+
pathParams: values(operation.params, 'path', variant, size),
|
|
33
|
+
query: values(operation.params, 'query', variant, size),
|
|
34
|
+
headers: {},
|
|
35
|
+
body: operation.requestBody
|
|
36
|
+
? sample(operation.requestBody.schema, variant, 0, operation.requestBody.schema)
|
|
37
|
+
: undefined,
|
|
38
|
+
seed: variant === 0 ? 1 : 2,
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
function values(params, where, variant, size) {
|
|
42
|
+
const out = {};
|
|
43
|
+
for (const p of params) {
|
|
44
|
+
if (p.in !== where) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// An optional query parameter is present in one probe and absent in the
|
|
48
|
+
// other, so a generator cannot assume either.
|
|
49
|
+
if (!p.required && variant === 1) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
out[p.name] =
|
|
53
|
+
p.name === size
|
|
54
|
+
? bounded(p.schema, PROBE_PAGE_SIZE, true)
|
|
55
|
+
: sample(p.schema, variant, 0, p.schema);
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
const NAMES = ['ada', 'grace', 'linus'];
|
|
60
|
+
/**
|
|
61
|
+
* A value the schema would accept. Not a faker — that job belongs to the
|
|
62
|
+
* generator; this only has to be *valid*, so that a probe failure is the
|
|
63
|
+
* generator's fault and never the probe's.
|
|
64
|
+
*/
|
|
65
|
+
function sample(schema, variant, depth, root) {
|
|
66
|
+
if (depth > 4) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
// Normalisation hoists anything shared or recursive, so a top-level schema
|
|
70
|
+
// is quite often nothing but a pointer into `$defs`.
|
|
71
|
+
const target = follow(schema, root);
|
|
72
|
+
if (target !== schema) {
|
|
73
|
+
return sample(target, variant, depth + 1, root);
|
|
74
|
+
}
|
|
75
|
+
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
|
|
76
|
+
return schema.enum[variant % schema.enum.length];
|
|
77
|
+
}
|
|
78
|
+
if (schema.const !== undefined) {
|
|
79
|
+
return schema.const;
|
|
80
|
+
}
|
|
81
|
+
for (const key of ['allOf', 'anyOf', 'oneOf']) {
|
|
82
|
+
const list = schema[key];
|
|
83
|
+
if (Array.isArray(list) && list.length > 0) {
|
|
84
|
+
return sample(list[0], variant, depth + 1, root);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
switch (typeOf(schema)) {
|
|
88
|
+
case 'integer':
|
|
89
|
+
return bounded(schema, variant === 0 ? 12324 : 7, true);
|
|
90
|
+
case 'number':
|
|
91
|
+
return bounded(schema, variant === 0 ? 42.5 : 1.5, false);
|
|
92
|
+
case 'boolean':
|
|
93
|
+
return variant === 0;
|
|
94
|
+
case 'null':
|
|
95
|
+
return null;
|
|
96
|
+
case 'array': {
|
|
97
|
+
const items = schema.items;
|
|
98
|
+
const one = typeof items === 'object' && items !== null
|
|
99
|
+
? sample(items, variant, depth + 1, root)
|
|
100
|
+
: 1;
|
|
101
|
+
return variant === 0 ? [one] : [one, one];
|
|
102
|
+
}
|
|
103
|
+
case 'object': {
|
|
104
|
+
const properties = (schema.properties ?? {});
|
|
105
|
+
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
106
|
+
const out = {};
|
|
107
|
+
for (const [name, sub] of Object.entries(properties)) {
|
|
108
|
+
if (variant === 1 && !required.includes(name)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
out[name] = sample(sub, variant, depth + 1, root);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
default:
|
|
116
|
+
return text(schema, variant);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** One hop through a local `#/$defs/...` pointer, or the schema itself. */
|
|
120
|
+
function follow(schema, root) {
|
|
121
|
+
const ref = schema.$ref;
|
|
122
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/$defs/')) {
|
|
123
|
+
return schema;
|
|
124
|
+
}
|
|
125
|
+
const defs = root.$defs;
|
|
126
|
+
if (typeof defs !== 'object' || defs === null) {
|
|
127
|
+
return schema;
|
|
128
|
+
}
|
|
129
|
+
const target = defs[ref.slice('#/$defs/'.length)];
|
|
130
|
+
return typeof target === 'object' && target !== null ? target : schema;
|
|
131
|
+
}
|
|
132
|
+
function typeOf(schema) {
|
|
133
|
+
const type = schema.type;
|
|
134
|
+
if (typeof type === 'string') {
|
|
135
|
+
return type;
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(type)) {
|
|
138
|
+
return type.find((t) => t !== 'null');
|
|
139
|
+
}
|
|
140
|
+
return schema.properties !== undefined
|
|
141
|
+
? 'object'
|
|
142
|
+
: schema.items !== undefined
|
|
143
|
+
? 'array'
|
|
144
|
+
: undefined;
|
|
145
|
+
}
|
|
146
|
+
function bounded(schema, wanted, integer) {
|
|
147
|
+
const min = num(schema.minimum) ?? num(schema.exclusiveMinimum);
|
|
148
|
+
const max = num(schema.maximum) ?? num(schema.exclusiveMaximum);
|
|
149
|
+
let value = wanted;
|
|
150
|
+
if (min !== undefined && value <= min) {
|
|
151
|
+
value = min + 1;
|
|
152
|
+
}
|
|
153
|
+
if (max !== undefined && value >= max) {
|
|
154
|
+
value = max - 1;
|
|
155
|
+
}
|
|
156
|
+
return integer ? Math.round(value) : value;
|
|
157
|
+
}
|
|
158
|
+
const num = (v) => (typeof v === 'number' ? v : undefined);
|
|
159
|
+
function text(schema, variant) {
|
|
160
|
+
switch (schema.format) {
|
|
161
|
+
case 'uuid':
|
|
162
|
+
return variant === 0
|
|
163
|
+
? '3f2504e0-4f89-11d3-9a0c-0305e82c3301'
|
|
164
|
+
: '9c858901-8a57-4791-81fe-4c455b099bc9';
|
|
165
|
+
case 'date':
|
|
166
|
+
return variant === 0 ? '2024-01-15' : '1999-12-31';
|
|
167
|
+
case 'date-time':
|
|
168
|
+
return variant === 0 ? '2024-01-15T09:30:00Z' : '1999-12-31T23:59:59Z';
|
|
169
|
+
case 'email':
|
|
170
|
+
return `${NAMES[variant % NAMES.length]}@example.com`;
|
|
171
|
+
case 'uri':
|
|
172
|
+
case 'url':
|
|
173
|
+
return 'https://example.com/thing';
|
|
174
|
+
default:
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
// A `pattern` cannot be satisfied by guessing, so it is left to the
|
|
178
|
+
// parameter check to tell us the probe was wrong rather than the generator.
|
|
179
|
+
const base = NAMES[variant % NAMES.length];
|
|
180
|
+
const min = num(schema.minLength) ?? 0;
|
|
181
|
+
return base.length >= min ? base : base.padEnd(min, 'x');
|
|
182
|
+
}
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
// The walk
|
|
185
|
+
//
|
|
186
|
+
// The probes above are independent, which is the right shape for everything one
|
|
187
|
+
// response can be wrong about and the wrong shape for pagination: a cursor only
|
|
188
|
+
// means anything in the answer it arrived with, so the pages have to be asked
|
|
189
|
+
// for in order.
|
|
190
|
+
//
|
|
191
|
+
// The seed is held still across the whole walk on purpose. A generator that
|
|
192
|
+
// mints its token out of `seed` rather than out of the request is the exact
|
|
193
|
+
// mistake being looked for, and a still seed makes it a fixed point — visible
|
|
194
|
+
// on the second page here instead of on somebody's client.
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
197
|
+
/** The first page: an ordinary probe with the paging control taken back off. */
|
|
198
|
+
export function walkStart(operation, paging) {
|
|
199
|
+
const input = probesFor(operation)[0];
|
|
200
|
+
if (paging.paramIn === 'body') {
|
|
201
|
+
const body = isObject(input.body) ? { ...input.body } : {};
|
|
202
|
+
delete body[paging.param];
|
|
203
|
+
return { ...input, body };
|
|
204
|
+
}
|
|
205
|
+
const query = { ...input.query };
|
|
206
|
+
delete query[paging.param];
|
|
207
|
+
return { ...input, query };
|
|
208
|
+
}
|
|
209
|
+
/** The same request again, asking for whatever the last answer pointed at. */
|
|
210
|
+
export function nextPage(previous, paging, token) {
|
|
211
|
+
if (paging.paramIn === 'body') {
|
|
212
|
+
const body = isObject(previous.body) ? { ...previous.body } : {};
|
|
213
|
+
body[paging.param] = token;
|
|
214
|
+
return { ...previous, body };
|
|
215
|
+
}
|
|
216
|
+
return { ...previous, query: { ...previous.query, [paging.param]: token } };
|
|
217
|
+
}
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// The echo rule
|
|
220
|
+
//
|
|
221
|
+
// `get_user_by_id(12324)` answering `{ user_id: 999 }` validates perfectly and
|
|
222
|
+
// is still wrong, so schema conformance is not the whole test. Where the
|
|
223
|
+
// response declares a property with a parameter's name, the parameter's value
|
|
224
|
+
// has to be the one that comes back.
|
|
225
|
+
//
|
|
226
|
+
// **Path parameters only.** A path segment identifies the resource, so a
|
|
227
|
+
// mismatch there is a broken mock. A query parameter is usually a control
|
|
228
|
+
// rather than content — `?source=realtime`, `?page_size=50`, `?cursor=…` — and
|
|
229
|
+
// real APIs collide those names with unrelated response properties all the
|
|
230
|
+
// time. Enforcing on them rejected working generators for a rule they were
|
|
231
|
+
// right to ignore. The prompt still asks for query echo where it is meaningful;
|
|
232
|
+
// it is simply not a reason to throw the file away.
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
export function echoIssues(input, value, schema) {
|
|
235
|
+
if (!schema) {
|
|
236
|
+
return [];
|
|
237
|
+
}
|
|
238
|
+
const declared = new Map();
|
|
239
|
+
for (const p of properties(schema)) {
|
|
240
|
+
declared.set(p.name, [...(declared.get(p.name) ?? []), p.schema]);
|
|
241
|
+
}
|
|
242
|
+
const out = [];
|
|
243
|
+
for (const [name, expected] of Object.entries(input.pathParams)) {
|
|
244
|
+
const subs = declared.get(name);
|
|
245
|
+
if (!subs || expected === undefined || expected === null) {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
// A value the response may not hold cannot be echoed: the schema check wins.
|
|
249
|
+
if (!subs.some((s) => admits(s, expected))) {
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!carries(value, name, expected)) {
|
|
253
|
+
out.push({
|
|
254
|
+
where: `/${name}`,
|
|
255
|
+
message: `must echo the path parameter ${JSON.stringify(expected)}, the response schema declares this property`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
function admits(schema, value) {
|
|
262
|
+
if (typeof schema.pattern !== 'string') {
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
return new RegExp(schema.pattern, 'u').test(String(value));
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Whether `name` anywhere in the value holds something equal to `expected`. */
|
|
273
|
+
function carries(value, name, expected) {
|
|
274
|
+
const stack = [value];
|
|
275
|
+
const seen = new Set();
|
|
276
|
+
while (stack.length > 0) {
|
|
277
|
+
const node = stack.pop();
|
|
278
|
+
if (typeof node !== 'object' || node === null) {
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (Array.isArray(node)) {
|
|
282
|
+
stack.push(...node);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (seen.has(node)) {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
seen.add(node);
|
|
289
|
+
const record = node;
|
|
290
|
+
if (name in record && same(record[name], expected)) {
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
stack.push(...Object.values(record));
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
/** A path segment is text on the wire; `"12324"` and `12324` are the same id. */
|
|
298
|
+
function same(got, expected) {
|
|
299
|
+
if (got === expected) {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
if (got === null || got === undefined || typeof got === 'object') {
|
|
303
|
+
return false;
|
|
304
|
+
}
|
|
305
|
+
return String(got) === String(expected);
|
|
306
|
+
}
|
|
307
|
+
//# sourceMappingURL=probe.js.map
|
package/dist/prompt.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { GeneratorInput } from './envelope.ts';
|
|
2
|
+
import { type Operation } from './spec.ts';
|
|
3
|
+
export declare const SYSTEM: string;
|
|
4
|
+
export declare function brief(operation: Operation): string;
|
|
5
|
+
/**
|
|
6
|
+
* Repeated in the file the model writes, so it is worth spelling out: the
|
|
7
|
+
* schema it validates against must be the one it was shown, embedded, not
|
|
8
|
+
* loaded from anywhere.
|
|
9
|
+
*/
|
|
10
|
+
export declare function instruction(operation: Operation): string;
|
|
11
|
+
export declare function retry(diagnostics: readonly string[]): string;
|
|
12
|
+
export interface ExampleRequest {
|
|
13
|
+
input: GeneratorInput;
|
|
14
|
+
output?: unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface RegenerationPromptContext {
|
|
17
|
+
operation: Operation;
|
|
18
|
+
currentSource: string;
|
|
19
|
+
failingInput: GeneratorInput;
|
|
20
|
+
fault: string;
|
|
21
|
+
stderr?: string;
|
|
22
|
+
examples?: readonly ExampleRequest[];
|
|
23
|
+
}
|
|
24
|
+
export declare function regenerationInstruction(ctx: RegenerationPromptContext): string;
|
|
25
|
+
//# sourceMappingURL=prompt.d.ts.map
|
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { AVAILABLE } from "./image.js";
|
|
2
|
+
import { called } from "./spec.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// What the model is told
|
|
5
|
+
//
|
|
6
|
+
// A contract, not a request. The generator is a program with a fixed calling
|
|
7
|
+
// convention that this package depends on in three places — the entry point,
|
|
8
|
+
// the input shape, the output file — so those are stated as rules rather than
|
|
9
|
+
// as suggestions, and everything discretionary is stated as taste.
|
|
10
|
+
//
|
|
11
|
+
// The one rule worth its own paragraph is the echo: a mock that answers
|
|
12
|
+
// `GET /users/12324` with somebody else's id is worse than useless, because it
|
|
13
|
+
// validates and quietly breaks whatever is being developed against it.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
export const SYSTEM = [
|
|
16
|
+
'You write one Python 3 file that fabricates a plausible HTTP response body.',
|
|
17
|
+
'',
|
|
18
|
+
'CALLING CONVENTION',
|
|
19
|
+
'- `sys.argv[1]` is a path to a JSON file to read; `sys.argv[2]` is a path to write to.',
|
|
20
|
+
'- Write exactly one JSON document to `sys.argv[2]` and print nothing to stdout.',
|
|
21
|
+
'- Exit non-zero with a message on stderr if you cannot produce a valid body.',
|
|
22
|
+
'',
|
|
23
|
+
'THE INPUT FILE holds:',
|
|
24
|
+
' operationId, method, path, pathParams, query, headers, body, seed',
|
|
25
|
+
'',
|
|
26
|
+
'ENVIRONMENT',
|
|
27
|
+
`- These libraries are installed: ${AVAILABLE.join(', ')}, plus the standard library.`,
|
|
28
|
+
'- There is no network and no pip. Importing anything else fails the file.',
|
|
29
|
+
'- Seed both `random.seed(seed)` and `Faker.seed(seed)` from the input, first thing.',
|
|
30
|
+
'',
|
|
31
|
+
'RULES',
|
|
32
|
+
'1. The output must validate against the response schema given below. Build the',
|
|
33
|
+
' object, then check it with `jsonschema.validate` before writing, and fix it',
|
|
34
|
+
' rather than emitting something that fails.',
|
|
35
|
+
'2. Echo the request. Where a path parameter has the same name as a property',
|
|
36
|
+
" in the response schema, that property must carry the request's value,",
|
|
37
|
+
' converted to the type the schema declares. `GET /users/{user_id}` called',
|
|
38
|
+
' with `user_id=12324` answers with `user_id` 12324, not a random one. Do',
|
|
39
|
+
' this generically, by looking the names up at run time. If the value would',
|
|
40
|
+
" break that property's schema (it fails a `pattern`, say), the schema wins:",
|
|
41
|
+
' generate a valid value instead, and do not exit non-zero. Do the same for a',
|
|
42
|
+
' query parameter where it plainly describes the content rather than',
|
|
43
|
+
' controlling the call. A paging control — a cursor, page, offset or',
|
|
44
|
+
' page-size parameter (whether in query or request body) — is never content',
|
|
45
|
+
' and must not be copied into the body; see PAGINATION below where the',
|
|
46
|
+
' operation has one.',
|
|
47
|
+
'3. Every required property must be present. Optional ones may be omitted',
|
|
48
|
+
' sometimes; that is what makes a mock useful.',
|
|
49
|
+
'4. Values must suit their names, not just their types. Use `faker` for anything',
|
|
50
|
+
' a person would recognise — names, emails, addresses, companies, phone',
|
|
51
|
+
' numbers, sentences. Use `exrex.getone(pattern)` when a string schema has a',
|
|
52
|
+
' `pattern`. Respect `enum`, `format`, `minimum`, `maxLength` and friends.',
|
|
53
|
+
'5. Arrays get 1 to 5 items unless the schema says otherwise.',
|
|
54
|
+
'6. The file is run once per request and must be deterministic for a given seed.',
|
|
55
|
+
'',
|
|
56
|
+
'ANSWER WITH THE FILE AND NOTHING ELSE. No explanation, no markdown fence.',
|
|
57
|
+
].join('\n');
|
|
58
|
+
export function brief(operation) {
|
|
59
|
+
const lines = [
|
|
60
|
+
`OPERATION ${operation.method.toUpperCase()} ${called(operation)}`,
|
|
61
|
+
`operationId: ${operation.operationId}`,
|
|
62
|
+
];
|
|
63
|
+
if (operation.summary) {
|
|
64
|
+
lines.push(`summary: ${operation.summary}`);
|
|
65
|
+
}
|
|
66
|
+
if (operation.description && operation.description !== operation.summary) {
|
|
67
|
+
lines.push(`description: ${operation.description.slice(0, 600)}`);
|
|
68
|
+
}
|
|
69
|
+
lines.push('', 'PARAMETERS');
|
|
70
|
+
if (operation.params.length === 0) {
|
|
71
|
+
lines.push(' (none)');
|
|
72
|
+
}
|
|
73
|
+
for (const p of operation.params) {
|
|
74
|
+
const note = p.description ? ` # ${p.description.split('\n')[0].slice(0, 100)}` : '';
|
|
75
|
+
lines.push(` ${p.name} (in ${p.in}${p.required ? ', required' : ''}): ${JSON.stringify(p.schema)}${note}`);
|
|
76
|
+
}
|
|
77
|
+
if (operation.requestBody) {
|
|
78
|
+
lines.push('', 'REQUEST BODY SCHEMA (arrives as `body`)', json(operation.requestBody.schema));
|
|
79
|
+
}
|
|
80
|
+
if (operation.paging) {
|
|
81
|
+
lines.push('', ...pagination(operation.paging));
|
|
82
|
+
}
|
|
83
|
+
lines.push('', `RESPONSE SCHEMA (status ${operation.success.status})`);
|
|
84
|
+
lines.push(operation.success.schema ? json(operation.success.schema) : ' (no body)');
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Said only to the operations that page, and said in terms of their own
|
|
89
|
+
* property names. The rule that earns the paragraph is the third one: a body
|
|
90
|
+
* offering the token it was just given passes the schema, passes the echo rule,
|
|
91
|
+
* and hangs every client that walks the list.
|
|
92
|
+
*/
|
|
93
|
+
function pagination(paging) {
|
|
94
|
+
const advance = paging.style === 'cursor'
|
|
95
|
+
? 'the base64 of a small JSON object holding the next page index, such as {"p": 2}'
|
|
96
|
+
: "the offset of the next page — this page's offset plus its size";
|
|
97
|
+
const where = paging.paramIn === 'body' ? 'In the request `body`, ' : '';
|
|
98
|
+
const lines = [
|
|
99
|
+
'PAGINATION',
|
|
100
|
+
` This operation is paged. ${where}\`${paging.param}\` asks for a page;`,
|
|
101
|
+
' absent or empty means the first one.',
|
|
102
|
+
' - Fabricate a random number of pages between 1 and 10 in total and no more.',
|
|
103
|
+
];
|
|
104
|
+
if (paging.next) {
|
|
105
|
+
lines.push(` - \`${paging.next}\` carries the token for the page after this one.`, ` Build it out of \`${paging.param}\`:`, ` ${advance}.`, ' - Never build it out of `seed`. Unpinned, the seed changes on every', ' request; pinned, it is a function of the query. A token made from it', ' either wanders or never changes.', ' - It must strictly advance. Answering with the token you were given is', ' the one failure that matters: a client following it loops forever.');
|
|
106
|
+
}
|
|
107
|
+
if (paging.more && !stuck(paging)) {
|
|
108
|
+
lines.push(` - \`${paging.more}\` is false on the last page and true before it.`);
|
|
109
|
+
}
|
|
110
|
+
if (paging.next) {
|
|
111
|
+
lines.push(...last(paging));
|
|
112
|
+
}
|
|
113
|
+
lines.push(' - A token you cannot read, or one past the end, is the last page,', ` ended the same way and with ${paging.items ? `\`${paging.items}\` empty` : 'nothing listed'}.`, ' Never an error, and never the first page again.');
|
|
114
|
+
return lines;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* How the last page says so. A token that is required and cannot be null has
|
|
118
|
+
* nowhere to put the ending, so the ending has to be said some other way —
|
|
119
|
+
* telling the model to null it anyway would only ask for an invalid body.
|
|
120
|
+
*/
|
|
121
|
+
const stuck = (paging) => !paging.nextNullable && paging.nextRequired === true;
|
|
122
|
+
function last(paging) {
|
|
123
|
+
if (paging.nextNullable) {
|
|
124
|
+
return [` - On the last page set \`${paging.next}\` to null.`];
|
|
125
|
+
}
|
|
126
|
+
if (!stuck(paging)) {
|
|
127
|
+
return [` - On the last page leave \`${paging.next}\` out.`];
|
|
128
|
+
}
|
|
129
|
+
const otherwise = [paging.more && `\`${paging.more}\` false`, paging.items && 'nothing listed']
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.join(' and ');
|
|
132
|
+
return [
|
|
133
|
+
` - The schema requires \`${paging.next}\` on every page, so the last page`,
|
|
134
|
+
` ends the list the other way: ${otherwise || 'an empty page'} (e.g. \`${paging.items || 'results'}\` as an empty list \`[]\`).`,
|
|
135
|
+
];
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Repeated in the file the model writes, so it is worth spelling out: the
|
|
139
|
+
* schema it validates against must be the one it was shown, embedded, not
|
|
140
|
+
* loaded from anywhere.
|
|
141
|
+
*/
|
|
142
|
+
export function instruction(operation) {
|
|
143
|
+
return [
|
|
144
|
+
brief(operation),
|
|
145
|
+
'',
|
|
146
|
+
'Embed the response schema in the file as a literal and validate against it.',
|
|
147
|
+
].join('\n');
|
|
148
|
+
}
|
|
149
|
+
export function retry(diagnostics) {
|
|
150
|
+
return [
|
|
151
|
+
'That file did not pass. Fix it and answer with the whole file again.',
|
|
152
|
+
'',
|
|
153
|
+
...diagnostics,
|
|
154
|
+
].join('\n');
|
|
155
|
+
}
|
|
156
|
+
export function regenerationInstruction(ctx) {
|
|
157
|
+
const { operation, currentSource, failingInput, fault, stderr, examples } = ctx;
|
|
158
|
+
const lines = [
|
|
159
|
+
brief(operation),
|
|
160
|
+
'',
|
|
161
|
+
'Embed the response schema in the file as a literal and validate against it.',
|
|
162
|
+
'',
|
|
163
|
+
'CURRENT GENERATOR CODE:',
|
|
164
|
+
'```python',
|
|
165
|
+
currentSource.trim(),
|
|
166
|
+
'```',
|
|
167
|
+
'',
|
|
168
|
+
'THE GENERATOR FAILED ON THIS REQUEST:',
|
|
169
|
+
'Input:',
|
|
170
|
+
json(failingInput),
|
|
171
|
+
'',
|
|
172
|
+
`Fault: ${fault}`,
|
|
173
|
+
];
|
|
174
|
+
if (stderr && stderr.trim()) {
|
|
175
|
+
lines.push('', 'Stderr / Traceback:', '```', stderr.trim(), '```');
|
|
176
|
+
}
|
|
177
|
+
if (examples && examples.length > 0) {
|
|
178
|
+
lines.push('', 'PREVIOUS SUCCESSFUL REQUESTS (use as examples):');
|
|
179
|
+
for (let i = 0; i < examples.length; i++) {
|
|
180
|
+
const ex = examples[i];
|
|
181
|
+
lines.push(`Example ${i + 1}:`, 'Input:', json(ex.input), 'Output produced:', json(ex.output), '');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
lines.push('TASK:', 'Fix the generator code so that it handles the failing request without error, produces valid output that matches the schema, and continues to produce valid output for the previous example requests.', 'ANSWER WITH THE COMPLETE REVISED PYTHON FILE AND NOTHING ELSE. No explanation, no markdown fence.');
|
|
185
|
+
return lines.join('\n');
|
|
186
|
+
}
|
|
187
|
+
const json = (value) => JSON.stringify(value, null, 1);
|
|
188
|
+
//# sourceMappingURL=prompt.js.map
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Method, type Operation } from './spec.ts';
|
|
2
|
+
export interface Match {
|
|
3
|
+
operation: Operation;
|
|
4
|
+
pathParams: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
export declare class Router {
|
|
7
|
+
#private;
|
|
8
|
+
readonly operations: readonly Operation[];
|
|
9
|
+
constructor(operations: readonly Operation[]);
|
|
10
|
+
match(method: string, pathname: string, search?: URLSearchParams): Match | undefined;
|
|
11
|
+
/** Whether the path exists under some other method — a 405, not a 404. */
|
|
12
|
+
allowed(pathname: string, search?: URLSearchParams): Method[];
|
|
13
|
+
/**
|
|
14
|
+
* The query strings this method would have answered on this path. Empty
|
|
15
|
+
* unless the document pinned one, which is the only way a request can miss
|
|
16
|
+
* a route whose path it matched exactly.
|
|
17
|
+
*/
|
|
18
|
+
expects(method: string, pathname: string): string[];
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=router.d.ts.map
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { SpecError } from "./spec.js";
|
|
2
|
+
export class Router {
|
|
3
|
+
#routes = new Map();
|
|
4
|
+
operations;
|
|
5
|
+
constructor(operations) {
|
|
6
|
+
this.operations = operations;
|
|
7
|
+
const seen = new Map();
|
|
8
|
+
for (const operation of operations) {
|
|
9
|
+
const fixed = Object.entries(operation.fixed ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
10
|
+
const id = `${operation.method} ${operation.path}${query(fixed)}`;
|
|
11
|
+
const clash = seen.get(id);
|
|
12
|
+
if (clash) {
|
|
13
|
+
throw new SpecError(`${id} is declared twice`, `${clash.source} and ${operation.source} both define it`);
|
|
14
|
+
}
|
|
15
|
+
seen.set(id, operation);
|
|
16
|
+
const segments = compile(operation.path);
|
|
17
|
+
const list = this.#routes.get(operation.method) ?? [];
|
|
18
|
+
list.push({
|
|
19
|
+
operation,
|
|
20
|
+
segments,
|
|
21
|
+
fixed,
|
|
22
|
+
specificity: segments.filter((s) => s.name === undefined).length,
|
|
23
|
+
});
|
|
24
|
+
this.#routes.set(operation.method, list);
|
|
25
|
+
}
|
|
26
|
+
for (const list of this.#routes.values()) {
|
|
27
|
+
list.sort((a, b) => b.fixed.length - a.fixed.length || b.specificity - a.specificity);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
match(method, pathname, search) {
|
|
31
|
+
const parts = split(pathname);
|
|
32
|
+
for (const route of this.#routes.get(method.toLowerCase()) ?? []) {
|
|
33
|
+
if (!pins(route, search)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const pathParams = apply(route.segments, parts);
|
|
37
|
+
if (pathParams) {
|
|
38
|
+
return { operation: route.operation, pathParams };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/** Whether the path exists under some other method — a 405, not a 404. */
|
|
44
|
+
allowed(pathname, search) {
|
|
45
|
+
const parts = split(pathname);
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const [method, list] of this.#routes) {
|
|
48
|
+
if (list.some((r) => pins(r, search) && apply(r.segments, parts) !== undefined)) {
|
|
49
|
+
out.push(method);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The query strings this method would have answered on this path. Empty
|
|
56
|
+
* unless the document pinned one, which is the only way a request can miss
|
|
57
|
+
* a route whose path it matched exactly.
|
|
58
|
+
*/
|
|
59
|
+
expects(method, pathname) {
|
|
60
|
+
const parts = split(pathname);
|
|
61
|
+
return (this.#routes.get(method.toLowerCase()) ?? [])
|
|
62
|
+
.filter((r) => r.fixed.length > 0 && apply(r.segments, parts) !== undefined)
|
|
63
|
+
.map((r) => query(r.fixed));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const query = (fixed) => fixed.length === 0 ? '' : `?${new URLSearchParams(fixed).toString()}`;
|
|
67
|
+
const pins = (route, search) => route.fixed.every(([name, value]) => search?.get(name) === value);
|
|
68
|
+
function compile(template) {
|
|
69
|
+
return split(template).map((raw) => {
|
|
70
|
+
const m = /^\{(.+)\}$/.exec(raw);
|
|
71
|
+
return m ? { name: m[1] } : { literal: decode(raw) };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const split = (path) => path.split('/').filter((s) => s.length > 0);
|
|
75
|
+
function apply(segments, parts) {
|
|
76
|
+
if (segments.length !== parts.length) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const out = {};
|
|
80
|
+
for (let i = 0; i < segments.length; i++) {
|
|
81
|
+
const segment = segments[i];
|
|
82
|
+
if (segment.name === undefined) {
|
|
83
|
+
if (segment.literal !== decode(parts[i])) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
out[segment.name] = decode(parts[i]);
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** A malformed escape is the client's problem; take the segment verbatim. */
|
|
93
|
+
function decode(part) {
|
|
94
|
+
try {
|
|
95
|
+
return decodeURIComponent(part);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return part;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=router.js.map
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type Schema = Record<string, unknown>;
|
|
2
|
+
export type Dialect = 'swagger-2.0' | 'openapi-3.0' | 'openapi-3.1';
|
|
3
|
+
/**
|
|
4
|
+
* Converts a dereferenced schema into 2020-12, hoisting anything reached more
|
|
5
|
+
* than once into `$defs`. The result is acyclic and safe to stringify.
|
|
6
|
+
*/
|
|
7
|
+
export declare function normalize(root: unknown, dialect: Dialect): Schema;
|
|
8
|
+
export interface Declared {
|
|
9
|
+
name: string;
|
|
10
|
+
/** the property's own schema, with a `$defs` pointer already followed */
|
|
11
|
+
schema: Schema;
|
|
12
|
+
/** whether the object declaring it lists it in `required` */
|
|
13
|
+
required: boolean;
|
|
14
|
+
/** how many objects deep it sits; 0 is the top level */
|
|
15
|
+
depth: number;
|
|
16
|
+
}
|
|
17
|
+
/** Every property a schema declares, at any depth, nearest first. */
|
|
18
|
+
export declare function properties(root: Schema): Declared[];
|
|
19
|
+
/** Every property name a schema mentions, at any depth. */
|
|
20
|
+
export declare const propertyNames: (root: Schema) => Set<string>;
|
|
21
|
+
//# sourceMappingURL=schema.d.ts.map
|