@zenera/faker 1.1.20 → 1.1.22
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/README.md +32 -13
- package/dist/cache.d.ts +14 -1
- package/dist/cache.js +57 -1
- package/dist/command.js +83 -9
- package/dist/generate.d.ts +11 -1
- package/dist/generate.js +60 -12
- package/dist/logger.d.ts +62 -0
- package/dist/logger.js +273 -0
- package/dist/page.d.ts +3 -0
- package/dist/page.js +499 -0
- package/dist/paging.d.ts +31 -3
- package/dist/paging.js +315 -6
- package/dist/probe.js +11 -0
- package/dist/prompt.d.ts +15 -1
- package/dist/prompt.js +40 -6
- package/dist/router.d.ts +8 -2
- package/dist/router.js +23 -5
- package/dist/server.d.ts +8 -0
- package/dist/server.js +346 -41
- package/dist/setup.d.ts +18 -0
- package/dist/setup.js +57 -15
- package/dist/spec.d.ts +9 -0
- package/dist/spec.js +45 -7
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -57,6 +57,12 @@ zen faker cache clear # throw it away
|
|
|
57
57
|
|
|
58
58
|
`zen mock` is the same command under a shorter noun.
|
|
59
59
|
|
|
60
|
+
A path key with a query string on it — `/x/{id}?action=retry`, which VMware NSX
|
|
61
|
+
and others write when they need two `post`s under one path — is read for what
|
|
62
|
+
it means rather than taken verbatim: the path is the part before the `?`, and
|
|
63
|
+
each pair after it becomes a required query value that tells those operations
|
|
64
|
+
apart. Call one without it and the 404 says which queries the path answers to.
|
|
65
|
+
|
|
60
66
|
## How a body is produced
|
|
61
67
|
|
|
62
68
|
The first time an operation is called, the faker asks a model to write a
|
|
@@ -86,20 +92,21 @@ nothing it shouldn't, and hands back `abc` again - so the client asks for the
|
|
|
86
92
|
same page forever.
|
|
87
93
|
|
|
88
94
|
The faker reads the document for this. Where an operation has a paging
|
|
89
|
-
parameter (`cursor`, `page`, `offset`, `page_token`, …)
|
|
90
|
-
that carries the next one (`next`, `next_cursor`, `has_more`, …),
|
|
91
|
-
happen, all in the operation's own names:
|
|
95
|
+
parameter (`cursor`, `page`, `offset`, `page_token`, …) in query or request body
|
|
96
|
+
and a response property that carries the next one (`next`, `next_cursor`, `has_more`, …),
|
|
97
|
+
three things happen, all in the operation's own names:
|
|
92
98
|
|
|
93
|
-
- the model is told to fabricate **
|
|
99
|
+
- the model is told to fabricate **between 1 and 10 pages** in total, to build the token
|
|
94
100
|
out of the paging parameter rather than the seed, and to end the list - null,
|
|
95
|
-
absent, or `has_more: false` where the schema leaves no other room;
|
|
101
|
+
absent, or `has_more: false` (and empty items) where the schema leaves no other room;
|
|
96
102
|
- the generator is then **walked**: the faker calls it with no cursor, follows
|
|
97
103
|
the token it gets back, and rejects the file if the token repeats, cycles, or
|
|
98
104
|
never runs out. The diagnostics say which, and the model gets another go;
|
|
99
|
-
- at request time a token identical to the one
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
- at request time, pagination is tracked and bounded: a token identical to the one
|
|
106
|
+
just sent is **cut** (nulled or dropped), cycles are stopped, and streams are capped
|
|
107
|
+
to random 1-10 pages, returning empty results if a client loops past the end. The
|
|
108
|
+
request line narrates each cut. Nothing is invented in its place; a generator written
|
|
109
|
+
before this rule existed is still cached, and a cache is not rebuilt because a rule changed.
|
|
103
110
|
|
|
104
111
|
Only paginated operations are affected. Their cache keys changed once, so they
|
|
105
112
|
are written again on first use; everything else keeps the key it had.
|
|
@@ -108,8 +115,8 @@ are written again on first use; everything else keeps the key it had.
|
|
|
108
115
|
Plenty of documents describe the envelope and never write down the parameter
|
|
109
116
|
that reads it back. The first two steps cannot help there - nothing static can
|
|
110
117
|
see a parameter that is not declared - but the cut still applies: it takes the
|
|
111
|
-
paging parameter from
|
|
112
|
-
because a body handed it X.
|
|
118
|
+
paging parameter from query parameters or request body, since a client only sends
|
|
119
|
+
`cursor=X` because a body handed it X.
|
|
113
120
|
|
|
114
121
|
## Commands
|
|
115
122
|
|
|
@@ -117,15 +124,27 @@ because a body handed it X.
|
|
|
117
124
|
zen faker serve <spec...> Serve them. Generators are written on demand.
|
|
118
125
|
zen faker build <spec...> Write every generator now and exit.
|
|
119
126
|
zen faker cache ls | clear What has been generated, or throw it away.
|
|
127
|
+
zen faker model [ref] Show or set the model it uses.
|
|
120
128
|
```
|
|
121
129
|
|
|
130
|
+
The model is looked for in this order: `--model`, `ZENERA_FAKER_MODEL`, what
|
|
131
|
+
`zen faker model <provider>:<id>` stored, then the best one your keys can buy.
|
|
132
|
+
The stored one lives beside the keyring in `~/.zenera/neo/faker.json`, so it is
|
|
133
|
+
remembered between runs and across projects; `zen faker model --clear` forgets
|
|
134
|
+
it.
|
|
135
|
+
|
|
122
136
|
Useful options: `--port`, `--host` (reachable only from this machine by
|
|
123
137
|
default), `--model`, `--seed` (same request, same answer), `--rebuild`,
|
|
124
138
|
`--attempts`, `--concurrency`, `--timeout`, `--cache <dir>` (the container's
|
|
125
139
|
workspace), `--quiet`. `zen help faker` prints the full table.
|
|
126
140
|
|
|
127
|
-
`GET
|
|
128
|
-
|
|
141
|
+
`GET /` is a contents page: every operation the mock is serving, grouped by the
|
|
142
|
+
document it came from, with its parameters and the shape of its answer. Each
|
|
143
|
+
one has a **Run** button, which opens a form — a box per parameter, required
|
|
144
|
+
ones marked, a body prefilled with something the mock will accept — and shows
|
|
145
|
+
the status, the time and the answer. `GET /__faker/routes` is the same thing as
|
|
146
|
+
JSON; `GET /__faker/health` is a health check. A document that declares `/`
|
|
147
|
+
itself keeps it.
|
|
129
148
|
|
|
130
149
|
## Credentials
|
|
131
150
|
|
package/dist/cache.d.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type { Model } from '@zenera/neo';
|
|
2
2
|
import type { Box } from './box.ts';
|
|
3
|
+
import type { GeneratorInput } from './envelope.ts';
|
|
3
4
|
import { BuildFailed } from './generate.ts';
|
|
4
|
-
import type {
|
|
5
|
+
import type { ExampleRequest } from './prompt.ts';
|
|
6
|
+
import { type Operation } from './spec.ts';
|
|
5
7
|
import type { Checks } from './validate.ts';
|
|
6
8
|
export declare const FAKER_KIND = "faker-generator";
|
|
7
9
|
/** What is kept about a generator besides the code, for `zen faker cache ls`. */
|
|
@@ -12,6 +14,7 @@ export interface GeneratorMeta {
|
|
|
12
14
|
source?: string;
|
|
13
15
|
model?: string;
|
|
14
16
|
attempts?: number;
|
|
17
|
+
rulesVersion?: number;
|
|
15
18
|
createdAt?: string;
|
|
16
19
|
}
|
|
17
20
|
export interface Generator {
|
|
@@ -53,6 +56,13 @@ export interface CacheOptions {
|
|
|
53
56
|
error: Error;
|
|
54
57
|
}) => void;
|
|
55
58
|
}
|
|
59
|
+
export interface CacheRegenerateContext {
|
|
60
|
+
currentSource: string;
|
|
61
|
+
failingInput: GeneratorInput;
|
|
62
|
+
fault: string;
|
|
63
|
+
stderr?: string;
|
|
64
|
+
examples?: readonly ExampleRequest[];
|
|
65
|
+
}
|
|
56
66
|
export declare class Cache {
|
|
57
67
|
#private;
|
|
58
68
|
constructor(opts: CacheOptions);
|
|
@@ -65,6 +75,9 @@ export declare class Cache {
|
|
|
65
75
|
* build and for everyone who waited on it, and true from then on.
|
|
66
76
|
*/
|
|
67
77
|
ensure(operation: Operation): Promise<Generator>;
|
|
78
|
+
get model(): Model;
|
|
79
|
+
getSource(key: string): Promise<string | undefined>;
|
|
80
|
+
regenerate(operation: Operation, ctx: CacheRegenerateContext): Promise<Generator>;
|
|
68
81
|
}
|
|
69
82
|
export { BuildFailed };
|
|
70
83
|
//# sourceMappingURL=cache.d.ts.map
|
package/dist/cache.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Cache as Store } from '@zenera/cli/lib';
|
|
2
|
-
import { build, BuildFailed } from "./generate.js";
|
|
2
|
+
import { build, BuildFailed, regenerate } from "./generate.js";
|
|
3
|
+
import { GENERATOR_RULES_VERSION } from "./spec.js";
|
|
3
4
|
// ---------------------------------------------------------------------------
|
|
4
5
|
// The cache
|
|
5
6
|
//
|
|
@@ -64,6 +65,60 @@ export class Cache {
|
|
|
64
65
|
this.#live.set(operation.key, started);
|
|
65
66
|
return started;
|
|
66
67
|
}
|
|
68
|
+
get model() {
|
|
69
|
+
return this.#opts.model;
|
|
70
|
+
}
|
|
71
|
+
async getSource(key) {
|
|
72
|
+
const found = this.#store.get(key);
|
|
73
|
+
return found?.source?.trim() ? found.source : undefined;
|
|
74
|
+
}
|
|
75
|
+
async regenerate(operation, ctx) {
|
|
76
|
+
const { box, model, checks, ephemeral } = this.#opts;
|
|
77
|
+
await this.#enter();
|
|
78
|
+
this.#opts.onStart?.({ operation });
|
|
79
|
+
try {
|
|
80
|
+
const built = await regenerate(operation, {
|
|
81
|
+
model,
|
|
82
|
+
box,
|
|
83
|
+
checks,
|
|
84
|
+
attempts: this.#opts.attempts,
|
|
85
|
+
currentSource: ctx.currentSource,
|
|
86
|
+
failingInput: ctx.failingInput,
|
|
87
|
+
fault: ctx.fault,
|
|
88
|
+
stderr: ctx.stderr,
|
|
89
|
+
examples: ctx.examples,
|
|
90
|
+
onAttempt: (attempt, diagnostics) => this.#opts.onAttempt?.({ operation, attempt, diagnostics }),
|
|
91
|
+
});
|
|
92
|
+
if (!ephemeral) {
|
|
93
|
+
this.#store.put(operation.key, {
|
|
94
|
+
source: built.source,
|
|
95
|
+
meta: {
|
|
96
|
+
operationId: operation.operationId,
|
|
97
|
+
method: operation.method,
|
|
98
|
+
path: operation.path,
|
|
99
|
+
source: operation.source,
|
|
100
|
+
model: model.id,
|
|
101
|
+
attempts: built.attempts,
|
|
102
|
+
rulesVersion: GENERATOR_RULES_VERSION,
|
|
103
|
+
createdAt: new Date().toISOString(),
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
this.#opts.onReady?.({ operation, cached: false, attempts: built.attempts });
|
|
108
|
+
const gen = { key: operation.key, source: built.source, cached: false };
|
|
109
|
+
this.#settled.add(operation.key);
|
|
110
|
+
this.#live.set(operation.key, Promise.resolve(gen));
|
|
111
|
+
return gen;
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
115
|
+
this.#opts.onFail?.({ operation, error });
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
this.#leave();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
67
122
|
async #make(operation) {
|
|
68
123
|
const { box, model, checks, rebuild, ephemeral } = this.#opts;
|
|
69
124
|
// Read before queueing: a cache hit costs nothing and must not wait
|
|
@@ -93,6 +148,7 @@ export class Cache {
|
|
|
93
148
|
source: operation.source,
|
|
94
149
|
model: model.id,
|
|
95
150
|
attempts: built.attempts,
|
|
151
|
+
rulesVersion: GENERATOR_RULES_VERSION,
|
|
96
152
|
createdAt: new Date().toISOString(),
|
|
97
153
|
},
|
|
98
154
|
});
|
package/dist/command.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { bold, cacheItems, clearCache, CliError, cyan, dim, EXIT, green, json, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
|
|
1
|
+
import { bold, cacheItems, clearCache, CliError, cyan, dim, EXIT, green, json, KeyStore, note, ownedContainers, parse, paths, red, removeContainers, table, usageError, write, writeAll, yellow, } from '@zenera/cli/lib';
|
|
2
|
+
import { createModel } from '@zenera/neo';
|
|
2
3
|
import { rmSync } from 'node:fs';
|
|
3
4
|
import { join, relative, resolve } from 'node:path';
|
|
4
5
|
import { GENERATORS } from "./box.js";
|
|
5
6
|
import { FAKER_KIND } from "./cache.js";
|
|
6
7
|
import { reason } from "./generate.js";
|
|
7
8
|
import { listen } from "./server.js";
|
|
8
|
-
import { open } from "./setup.js";
|
|
9
|
-
import {} from "./spec.js";
|
|
9
|
+
import { chooseModel, MODEL_ENV, open, readSettings, SOURCE_LABELS, writeSettings, } from "./setup.js";
|
|
10
|
+
import { called } from "./spec.js";
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// zen faker — a mock API from a specification
|
|
12
13
|
//
|
|
@@ -18,7 +19,7 @@ import {} from "./spec.js";
|
|
|
18
19
|
// resolve until a signal arrives, and the process lives as long as the answer
|
|
19
20
|
// takes, which is what every other command already means.
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
21
|
-
const USAGE = 'zen faker <serve|build|cache> [spec...]';
|
|
22
|
+
const USAGE = 'zen faker <serve|build|cache|model> [spec...]';
|
|
22
23
|
// `--json` is the frame's, lifted out of the arguments before they arrive; it
|
|
23
24
|
// reaches us as `ctx.json` and must not be declared again or `strict` rejects it.
|
|
24
25
|
const OPTIONS = {
|
|
@@ -32,8 +33,11 @@ const OPTIONS = {
|
|
|
32
33
|
seed: { type: 'string' },
|
|
33
34
|
timeout: { type: 'string' },
|
|
34
35
|
'max-body': { type: 'string' },
|
|
36
|
+
'regen-limit': { type: 'string' },
|
|
37
|
+
'requests-dir': { type: 'string' },
|
|
35
38
|
rebuild: { type: 'boolean' },
|
|
36
39
|
'no-cache': { type: 'boolean' },
|
|
40
|
+
clear: { type: 'boolean' },
|
|
37
41
|
quiet: { type: 'boolean' },
|
|
38
42
|
};
|
|
39
43
|
export const command = {
|
|
@@ -45,13 +49,14 @@ export const command = {
|
|
|
45
49
|
[' serve <spec...>', dim('Serve the documents. Generators are written on demand.')],
|
|
46
50
|
[' build <spec...>', dim('Write every generator now and exit.')],
|
|
47
51
|
[' cache ls|clear', dim('What has been generated, or throw it away.')],
|
|
52
|
+
[' model [ref]', dim('Show or set the model it uses. Remembered between runs.')],
|
|
48
53
|
]),
|
|
49
54
|
'',
|
|
50
55
|
'Options',
|
|
51
56
|
...table([
|
|
52
57
|
[' --port <n>', dim('Default 8787.')],
|
|
53
58
|
[' --host <h>', dim('Default 127.0.0.1. Anything else is reachable off-machine.')],
|
|
54
|
-
[' --model <ref>', dim('Which model writes the generators.')],
|
|
59
|
+
[' --model <ref>', dim('Which model writes the generators, for this run only.')],
|
|
55
60
|
[' --image <ref>', dim('Skip the baked image and use this one.')],
|
|
56
61
|
[' --cache <dir>', dim("The container's workspace. Default ~/.zenera/neo/faker.")],
|
|
57
62
|
[' --seed <n>', dim('Answer the same request the same way every time.')],
|
|
@@ -59,11 +64,16 @@ export const command = {
|
|
|
59
64
|
[' --concurrency <n>', dim('Generators written at once. Default 4.')],
|
|
60
65
|
[' --timeout <s>', dim('Seconds one generator may take. Default 30.')],
|
|
61
66
|
[' --max-body <n>', dim('Largest request body accepted, in bytes.')],
|
|
67
|
+
[' --regen-limit <n>', dim('Maximum regenerations per URL on error. Default 3.')],
|
|
68
|
+
[' --requests-dir <dir>', dim('Where to save request/response .md files.')],
|
|
62
69
|
[' --rebuild', dim('Ignore what is cached and write it again.')],
|
|
63
70
|
[' --no-cache', dim('Do not record what is written.')],
|
|
71
|
+
[' --clear', dim('With `model`: forget the stored one.')],
|
|
64
72
|
[' --quiet', dim('No narration.')],
|
|
65
73
|
]),
|
|
66
74
|
'',
|
|
75
|
+
dim(`The model is looked for in this order: ${cyan('--model')}, ${cyan(MODEL_ENV)}, ` +
|
|
76
|
+
`${cyan('zen faker model')}, then the best one your keys can buy.`),
|
|
67
77
|
dim(`Credentials come from the ${cyan('zen')} keyring — try ${cyan('zen key ls')}.`),
|
|
68
78
|
],
|
|
69
79
|
async run(ctx) {
|
|
@@ -75,6 +85,8 @@ export const command = {
|
|
|
75
85
|
return await warm(rest, ctx);
|
|
76
86
|
case 'cache':
|
|
77
87
|
return await cache(rest, ctx);
|
|
88
|
+
case 'model':
|
|
89
|
+
return await model(rest, ctx);
|
|
78
90
|
default:
|
|
79
91
|
throw usageError(name ? `unknown command "${name}"` : 'no command given', USAGE);
|
|
80
92
|
}
|
|
@@ -91,6 +103,9 @@ async function serve(args, ctx) {
|
|
|
91
103
|
printSpecs(setup.router.operations, ctx.cwd);
|
|
92
104
|
}
|
|
93
105
|
const host = values.host ?? '127.0.0.1';
|
|
106
|
+
const requestsDir = values['requests-dir']
|
|
107
|
+
? resolve(ctx.cwd, values['requests-dir'])
|
|
108
|
+
: undefined;
|
|
94
109
|
const listener = await listen({
|
|
95
110
|
router: setup.router,
|
|
96
111
|
cache: setup.cache,
|
|
@@ -98,7 +113,9 @@ async function serve(args, ctx) {
|
|
|
98
113
|
box: setup.box,
|
|
99
114
|
seed: number(values.seed, 'seed'),
|
|
100
115
|
maxBody: number(values['max-body'], 'max-body'),
|
|
101
|
-
|
|
116
|
+
requestsDir,
|
|
117
|
+
regenLimit: number(values['regen-limit'], 'regen-limit'),
|
|
118
|
+
onRequest: values.quiet ? undefined : (line) => note(line),
|
|
102
119
|
}, host, number(values.port, 'port') ?? 8787);
|
|
103
120
|
if (!values.quiet) {
|
|
104
121
|
note(`${green('listening')} ${cyan(`http://${host}:${listener.port}`)} ` +
|
|
@@ -107,6 +124,7 @@ async function serve(args, ctx) {
|
|
|
107
124
|
note(yellow(`bound to ${host} — this mock is reachable from the network`));
|
|
108
125
|
}
|
|
109
126
|
note(dim(`generators: ${setup.root}/${GENERATORS}`));
|
|
127
|
+
note(dim(`request logs: ${requestsDir ?? `${setup.root}/requests`}`));
|
|
110
128
|
}
|
|
111
129
|
// The address is the answer; the rest was narration.
|
|
112
130
|
write(`http://${host}:${listener.port}`);
|
|
@@ -144,7 +162,7 @@ async function warm(args, ctx) {
|
|
|
144
162
|
const results = [];
|
|
145
163
|
try {
|
|
146
164
|
for (const operation of setup.router.operations) {
|
|
147
|
-
const id = `${operation.method.toUpperCase()} ${operation
|
|
165
|
+
const id = `${operation.method.toUpperCase()} ${called(operation)}`;
|
|
148
166
|
if (!operation.success.schema) {
|
|
149
167
|
results.push({ operation: id, status: 'skipped', detail: 'no response body' });
|
|
150
168
|
continue;
|
|
@@ -238,6 +256,58 @@ function listGenerators() {
|
|
|
238
256
|
.map((row) => ({ key: row.key, ...row.value?.meta }))
|
|
239
257
|
.sort((a, b) => a.key.localeCompare(b.key));
|
|
240
258
|
}
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// model
|
|
261
|
+
//
|
|
262
|
+
// A stored model is remembered the way `zen meta model` remembers one: in the
|
|
263
|
+
// home directory, beside the keyring, because it is a choice about this tool on
|
|
264
|
+
// this machine. Shown rather than merely set, since the question when a run
|
|
265
|
+
// surprises you is not "which model" but "why that one".
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
const MODEL_USAGE = 'zen faker model [ref|--clear]';
|
|
268
|
+
async function model(args, ctx) {
|
|
269
|
+
const { values, positionals } = parse(args, OPTIONS, MODEL_USAGE);
|
|
270
|
+
const ref = positionals[0];
|
|
271
|
+
if (ref && values.clear) {
|
|
272
|
+
throw usageError('--clear takes no model', MODEL_USAGE);
|
|
273
|
+
}
|
|
274
|
+
// Before anything builds a model: vertex reads its project out of the
|
|
275
|
+
// environment, and the keyring is what puts it there.
|
|
276
|
+
const keys = await KeyStore.open();
|
|
277
|
+
keys.materialize();
|
|
278
|
+
if (ref || values.clear) {
|
|
279
|
+
const file = await readSettings();
|
|
280
|
+
if (ref) {
|
|
281
|
+
// Only that the ref parses: asking the provider costs a call, and
|
|
282
|
+
// the next run makes one anyway.
|
|
283
|
+
createModel(ref);
|
|
284
|
+
writeSettings({ ...file, model: ref });
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
delete file.model;
|
|
288
|
+
writeSettings(file);
|
|
289
|
+
}
|
|
290
|
+
if (ctx.json) {
|
|
291
|
+
json({ model: ref });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
note(ref ? `${green('set')} ${ref}` : `${green('cleared')} ${dim('the stored model')}`);
|
|
295
|
+
}
|
|
296
|
+
const chosen = await chooseModel(values.model, keys);
|
|
297
|
+
if (ctx.json) {
|
|
298
|
+
json({ model: chosen?.ref, from: chosen?.from, stored: (await readSettings()).model });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
if (!chosen) {
|
|
302
|
+
note('no model, and no key to recommend one from');
|
|
303
|
+
note(dim('add a key: zen key add openai'));
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
note(`${cyan(chosen.ref)} ${dim(`from ${SOURCE_LABELS[chosen.from]}`)}`);
|
|
307
|
+
if (chosen.from === 'default') {
|
|
308
|
+
note(dim(`set one: zen faker model <provider>:<id>`));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
241
311
|
function summarize(operations) {
|
|
242
312
|
const by = new Map();
|
|
243
313
|
for (const op of operations) {
|
|
@@ -288,7 +358,7 @@ function printSpecs(operations, cwd) {
|
|
|
288
358
|
// ---------------------------------------------------------------------------
|
|
289
359
|
async function start(values, specs, ctx, what) {
|
|
290
360
|
const loud = !values.quiet && !ctx.json;
|
|
291
|
-
|
|
361
|
+
const setup = await open({
|
|
292
362
|
specs,
|
|
293
363
|
cwd: ctx.cwd,
|
|
294
364
|
cache: values.cache,
|
|
@@ -304,7 +374,7 @@ async function start(values, specs, ctx, what) {
|
|
|
304
374
|
: undefined,
|
|
305
375
|
events: {
|
|
306
376
|
onStart: loud
|
|
307
|
-
? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${operation
|
|
377
|
+
? ({ operation }) => note(`${dim('writing a generator for')} ${operation.method.toUpperCase()} ${called(operation)}`)
|
|
308
378
|
: undefined,
|
|
309
379
|
onAttempt: loud
|
|
310
380
|
? ({ operation, attempt, diagnostics }) => note(` ${yellow(`attempt ${attempt} failed`)} ${dim(`${operation.operationId}: ${(diagnostics ?? []).join(' ').slice(0, 160)}`)}`)
|
|
@@ -319,6 +389,10 @@ async function start(values, specs, ctx, what) {
|
|
|
319
389
|
: undefined,
|
|
320
390
|
},
|
|
321
391
|
});
|
|
392
|
+
if (loud) {
|
|
393
|
+
note(dim(`model ${setup.choice.ref} from ${SOURCE_LABELS[setup.choice.from]}`));
|
|
394
|
+
}
|
|
395
|
+
return setup;
|
|
322
396
|
}
|
|
323
397
|
function number(raw, what) {
|
|
324
398
|
if (raw === undefined) {
|
package/dist/generate.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Model } from '@zenera/neo';
|
|
2
2
|
import type { Box } from './box.ts';
|
|
3
|
-
import type {
|
|
3
|
+
import type { GeneratorInput } from './envelope.ts';
|
|
4
|
+
import { type ExampleRequest } from './prompt.ts';
|
|
5
|
+
import { type Operation } from './spec.ts';
|
|
4
6
|
import { type Checks } from './validate.ts';
|
|
5
7
|
export interface BuildOptions {
|
|
6
8
|
model: Model;
|
|
@@ -15,6 +17,13 @@ export interface Built {
|
|
|
15
17
|
source: string;
|
|
16
18
|
attempts: number;
|
|
17
19
|
}
|
|
20
|
+
export interface RegenerateOptions extends BuildOptions {
|
|
21
|
+
currentSource: string;
|
|
22
|
+
failingInput: GeneratorInput;
|
|
23
|
+
fault: string;
|
|
24
|
+
stderr?: string;
|
|
25
|
+
examples?: readonly ExampleRequest[];
|
|
26
|
+
}
|
|
18
27
|
export declare class BuildFailed extends Error {
|
|
19
28
|
readonly operation: Operation;
|
|
20
29
|
readonly diagnostics: readonly string[];
|
|
@@ -30,6 +39,7 @@ export declare class BuildFailed extends Error {
|
|
|
30
39
|
*/
|
|
31
40
|
export declare function reason(err: unknown): string;
|
|
32
41
|
export declare function build(operation: Operation, opts: BuildOptions): Promise<Built>;
|
|
42
|
+
export declare function regenerate(operation: Operation, opts: RegenerateOptions): Promise<Built>;
|
|
33
43
|
/**
|
|
34
44
|
* Models fence code even when told not to, and a stray ```python line is a
|
|
35
45
|
* syntax error rather than a bad answer — not worth a round trip.
|
package/dist/generate.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { tokenOf } from "./paging.js";
|
|
2
2
|
import { echoIssues, nextPage, probesFor, walkStart } from "./probe.js";
|
|
3
|
-
import { instruction, retry, SYSTEM } from "./prompt.js";
|
|
3
|
+
import { instruction, regenerationInstruction, retry, SYSTEM, } from "./prompt.js";
|
|
4
|
+
import { called } from "./spec.js";
|
|
4
5
|
import { describeIssues, issues } from "./validate.js";
|
|
5
6
|
export class BuildFailed extends Error {
|
|
6
7
|
operation;
|
|
7
8
|
diagnostics;
|
|
8
9
|
constructor(operation, diagnostics) {
|
|
9
|
-
super(`could not write a generator for ${operation.method.toUpperCase()} ${operation
|
|
10
|
+
super(`could not write a generator for ${operation.method.toUpperCase()} ${called(operation)}`);
|
|
10
11
|
this.name = 'BuildFailed';
|
|
11
12
|
this.operation = operation;
|
|
12
13
|
this.diagnostics = diagnostics;
|
|
@@ -70,6 +71,53 @@ export async function build(operation, opts) {
|
|
|
70
71
|
}
|
|
71
72
|
throw new BuildFailed(operation, last);
|
|
72
73
|
}
|
|
74
|
+
export async function regenerate(operation, opts) {
|
|
75
|
+
const limit = Math.max(1, opts.attempts ?? 3);
|
|
76
|
+
const exampleInputs = opts.examples?.map((e) => e.input) ?? [];
|
|
77
|
+
const probes = [opts.failingInput, ...exampleInputs, ...probesFor(operation)];
|
|
78
|
+
const response = opts.checks.for(operation).response;
|
|
79
|
+
const messages = [
|
|
80
|
+
{
|
|
81
|
+
role: 'user',
|
|
82
|
+
content: [
|
|
83
|
+
{
|
|
84
|
+
type: 'text',
|
|
85
|
+
text: regenerationInstruction({
|
|
86
|
+
operation,
|
|
87
|
+
currentSource: opts.currentSource,
|
|
88
|
+
failingInput: opts.failingInput,
|
|
89
|
+
fault: opts.fault,
|
|
90
|
+
stderr: opts.stderr,
|
|
91
|
+
examples: opts.examples,
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
];
|
|
97
|
+
let last = [];
|
|
98
|
+
for (let attempt = 1; attempt <= limit; attempt++) {
|
|
99
|
+
const answer = await ask(opts, {
|
|
100
|
+
system: SYSTEM,
|
|
101
|
+
messages,
|
|
102
|
+
tools: [],
|
|
103
|
+
signal: opts.signal,
|
|
104
|
+
});
|
|
105
|
+
const source = unfence(answer.text);
|
|
106
|
+
if (!source.trim()) {
|
|
107
|
+
last = ['the answer was empty'];
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
await opts.box.write(operation.key, source);
|
|
111
|
+
last = await judge(operation, probes, response, opts.box);
|
|
112
|
+
if (last.length === 0) {
|
|
113
|
+
return { source, attempts: attempt };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
opts.onAttempt?.(attempt, last);
|
|
117
|
+
messages.push({ role: 'assistant', content: source }, { role: 'user', content: [{ type: 'text', text: retry(last) }] });
|
|
118
|
+
}
|
|
119
|
+
throw new BuildFailed(operation, last);
|
|
120
|
+
}
|
|
73
121
|
/**
|
|
74
122
|
* Streamed when the adapter can, and every delta is dropped: nothing here
|
|
75
123
|
* renders progress. It is the *socket* that needs them.
|
|
@@ -90,9 +138,9 @@ async function judge(operation, probes, response, box) {
|
|
|
90
138
|
const out = [];
|
|
91
139
|
for (const probe of probes) {
|
|
92
140
|
const outcome = await box.run(operation.key, probe);
|
|
93
|
-
const
|
|
141
|
+
const what = `input ${JSON.stringify({ pathParams: probe.pathParams, query: probe.query })}`;
|
|
94
142
|
if (!outcome.ok) {
|
|
95
|
-
out.push(`- ${
|
|
143
|
+
out.push(`- ${what}: the file ${outcome.fault}.`);
|
|
96
144
|
if (outcome.stderr) {
|
|
97
145
|
out.push(` stderr: ${tail(outcome.stderr)}`);
|
|
98
146
|
}
|
|
@@ -100,11 +148,11 @@ async function judge(operation, probes, response, box) {
|
|
|
100
148
|
break;
|
|
101
149
|
}
|
|
102
150
|
if (response && !response(outcome.value)) {
|
|
103
|
-
out.push(`- ${
|
|
151
|
+
out.push(`- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`);
|
|
104
152
|
}
|
|
105
153
|
const echo = echoIssues(probe, outcome.value, operation.success.schema);
|
|
106
154
|
if (echo.length > 0) {
|
|
107
|
-
out.push(`- ${
|
|
155
|
+
out.push(`- ${what}: ${describeIssues(echo)}.`);
|
|
108
156
|
}
|
|
109
157
|
}
|
|
110
158
|
// Only worth the container round trips once the file answers at all, and
|
|
@@ -127,13 +175,13 @@ async function walk(operation, paging, response, box) {
|
|
|
127
175
|
let sent;
|
|
128
176
|
for (let page = 1; page <= MAX_PAGES; page++) {
|
|
129
177
|
const outcome = await box.run(operation.key, input);
|
|
130
|
-
const
|
|
178
|
+
const what = `page ${page} of ${operation.method.toUpperCase()} ${called(operation)}`;
|
|
131
179
|
if (!outcome.ok) {
|
|
132
|
-
return [`- ${
|
|
180
|
+
return [`- ${what}: the file ${outcome.fault}.`];
|
|
133
181
|
}
|
|
134
182
|
if (response && !response(outcome.value)) {
|
|
135
183
|
return [
|
|
136
|
-
`- ${
|
|
184
|
+
`- ${what}: the output does not match the response schema — ${describeIssues(issues('', response.errors))}.`,
|
|
137
185
|
];
|
|
138
186
|
}
|
|
139
187
|
const token = tokenOf(outcome.value, paging);
|
|
@@ -142,12 +190,12 @@ async function walk(operation, paging, response, box) {
|
|
|
142
190
|
}
|
|
143
191
|
if (token === sent) {
|
|
144
192
|
return [
|
|
145
|
-
`- ${
|
|
193
|
+
`- ${what}: \`${paging.next}\` came back as ${JSON.stringify(token)}, the very token the request carried in \`${paging.param}\`. A client following it never advances. Build the token from \`${paging.param}\` so it counts up, and stop after pagination ends.`,
|
|
146
194
|
];
|
|
147
195
|
}
|
|
148
196
|
if (seen.has(token)) {
|
|
149
197
|
return [
|
|
150
|
-
`- ${
|
|
198
|
+
`- ${what}: the page tokens cycle — ${JSON.stringify(token)} was handed out earlier in this walk. Every page must offer a token no page has offered before, and the last one must offer none.`,
|
|
151
199
|
];
|
|
152
200
|
}
|
|
153
201
|
seen.add(token);
|
|
@@ -155,7 +203,7 @@ async function walk(operation, paging, response, box) {
|
|
|
155
203
|
input = nextPage(input, paging, token);
|
|
156
204
|
}
|
|
157
205
|
return [
|
|
158
|
-
`- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Fabricate
|
|
206
|
+
`- ${operation.method.toUpperCase()} ${operation.path}: the pages never run out — after ${MAX_PAGES} of them \`${paging.next}\` is still set. Fabricate between 1 and 10 pages in total and set it to null on the last.`,
|
|
159
207
|
];
|
|
160
208
|
}
|
|
161
209
|
/**
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { GeneratorInput } from './envelope.ts';
|
|
2
|
+
export type ErrorKind = 'CLIENT_ERROR: NOT_FOUND' | 'CLIENT_ERROR: METHOD_NOT_ALLOWED' | 'CLIENT_ERROR: BAD_REQUEST' | 'CLIENT_ERROR: VALIDATION_FAILED' | 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' | 'GENERATOR_FAULT' | 'GENERATOR_TIMEOUT' | 'GENERATOR_SCHEMA_MISMATCH' | 'GENERATOR_BUILD_FAILED' | 'SERVER_INTERNAL_ERROR' | 'REGENERATION_LIMIT_HIT' | 'REGENERATION_FAILED' | 'SUCCESS';
|
|
3
|
+
export interface RequestDumpData {
|
|
4
|
+
id: string;
|
|
5
|
+
timestamp: Date;
|
|
6
|
+
durationMs: number;
|
|
7
|
+
method: string;
|
|
8
|
+
url: string;
|
|
9
|
+
pathname: string;
|
|
10
|
+
search: string;
|
|
11
|
+
operationId?: string;
|
|
12
|
+
routePath?: string;
|
|
13
|
+
pathParams?: Record<string, string>;
|
|
14
|
+
query?: Record<string, string>;
|
|
15
|
+
requestHeaders: Record<string, string>;
|
|
16
|
+
requestBody?: unknown;
|
|
17
|
+
status: number;
|
|
18
|
+
responseHeaders?: Record<string, string | number>;
|
|
19
|
+
responseBody?: unknown;
|
|
20
|
+
errorKind?: ErrorKind;
|
|
21
|
+
errorMessage?: string;
|
|
22
|
+
stderr?: string;
|
|
23
|
+
cacheStatus?: 'hit' | 'miss' | 'regenerated';
|
|
24
|
+
regenerated?: boolean;
|
|
25
|
+
regenAttempts?: number;
|
|
26
|
+
regenLimitHit?: boolean;
|
|
27
|
+
regenLimit?: number;
|
|
28
|
+
issues?: readonly unknown[];
|
|
29
|
+
note?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function formatTimestamp(d: Date): string;
|
|
32
|
+
export declare function statusText(status: number): string;
|
|
33
|
+
export declare function colorForStatus(status: number): (s: string) => string;
|
|
34
|
+
export declare function colorForErrorKind(kind: ErrorKind): (s: string) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Saves a markdown file with all input information and output data for a request.
|
|
37
|
+
* Returns the absolute path of the generated markdown file.
|
|
38
|
+
*/
|
|
39
|
+
export declare function saveRequestDump(dir: string, data: RequestDumpData): Promise<string>;
|
|
40
|
+
export declare function formatLogLine(data: RequestDumpData, dumpPath: string): string;
|
|
41
|
+
export declare function formatRegenLimitHit(urlPath: string, limit: number, operationId?: string): string;
|
|
42
|
+
export declare function formatRegenerating(urlPath: string, attempt: number, limit: number, operationId?: string, reason?: string): string;
|
|
43
|
+
export declare function formatRegenerated(urlPath: string, attempt: number, durationMs: number, operationId?: string): string;
|
|
44
|
+
export interface ExampleRequest {
|
|
45
|
+
input: GeneratorInput;
|
|
46
|
+
output?: unknown;
|
|
47
|
+
}
|
|
48
|
+
export declare class RequestHistory {
|
|
49
|
+
#private;
|
|
50
|
+
constructor(maxExamples?: number);
|
|
51
|
+
recordSuccess(operationKey: string, input: GeneratorInput, output: unknown): void;
|
|
52
|
+
getSuccessful(operationKey: string, count?: number): ExampleRequest[];
|
|
53
|
+
}
|
|
54
|
+
export declare class RegenerationLimiter {
|
|
55
|
+
#private;
|
|
56
|
+
readonly limit: number;
|
|
57
|
+
constructor(limit?: number);
|
|
58
|
+
countFor(url: string): number;
|
|
59
|
+
isLimitHit(url: string): boolean;
|
|
60
|
+
increment(url: string): number;
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=logger.d.ts.map
|