@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/dist/server.js CHANGED
@@ -1,8 +1,12 @@
1
- import { createHash, randomInt } from 'node:crypto';
1
+ import { createHash, randomInt, randomUUID } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
2
3
  import { createServer } from 'node:http';
4
+ import { join } from 'node:path';
3
5
  import { BuildFailed } from "./cache.js";
4
6
  import { reason } from "./generate.js";
5
- import { cutLoop, pagingSeen } from "./paging.js";
7
+ import { formatLogLine, formatRegenerated, formatRegenerating, formatRegenLimitHit, RegenerationLimiter, RequestHistory, saveRequestDump, } from "./logger.js";
8
+ import { indexPage } from "./page.js";
9
+ import { cutLoop, getSentToken, PaginationLimiter, pagingSeen, } from "./paging.js";
6
10
  import { describeIssues, issues } from "./validate.js";
7
11
  // ---------------------------------------------------------------------------
8
12
  // The server
@@ -22,11 +26,22 @@ const REDACTED = /^(authorization|cookie|set-cookie|proxy-authorization)$/i;
22
26
  const SECRETISH = /key|token|secret|password|credential/i;
23
27
  const DEFAULT_MAX_BODY = 1024 * 1024;
24
28
  export function build(opts) {
29
+ const history = opts.history ?? new RequestHistory();
30
+ const limiter = opts.limiter ?? new RegenerationLimiter(opts.regenLimit ?? 3);
31
+ const paginationLimiter = opts.paginationLimiter ?? new PaginationLimiter(opts.maxPages);
32
+ const requestsDir = opts.requestsDir ?? join(opts.box.root, 'requests');
33
+ const enriched = {
34
+ ...opts,
35
+ history,
36
+ limiter,
37
+ paginationLimiter,
38
+ requestsDir,
39
+ };
25
40
  return createServer((req, res) => {
26
- handle(req, res, opts).catch((err) => {
27
- // Nothing below is expected to throw; if it does, the client still
28
- // gets an answer and the operator still gets the reason.
29
- send(res, 500, { error: err instanceof Error ? err.message : String(err) });
41
+ handle(req, res, enriched).catch((err) => {
42
+ const message = err instanceof Error ? err.message : String(err);
43
+ send(res, 500, { error: message });
44
+ opts.onRequest?.(`${req.method ?? 'GET'} ${req.url ?? '/'} 500 [SERVER_INTERNAL_ERROR] ${message}`);
30
45
  });
31
46
  });
32
47
  }
@@ -53,27 +68,115 @@ export async function listen(opts, host, port) {
53
68
  // ---------------------------------------------------------------------------
54
69
  async function handle(req, res, opts) {
55
70
  const started = Date.now();
71
+ const timestamp = new Date(started);
72
+ const id = randomUUID().slice(0, 8);
56
73
  const url = new URL(req.url ?? '/', 'http://localhost');
57
74
  const method = (req.method ?? 'GET').toLowerCase();
58
- // The search string is part of the request: without it a 400 caused by a
59
- // query parameter is unexplainable from the log.
60
- const say = (status, what) => opts.onRequest?.(`${req.method} ${url.pathname}${url.search} ${status} ${Date.now() - started}ms ${what}`);
75
+ const finish = async (status, meta) => {
76
+ const responseHeaders = Object.fromEntries(Object.entries(res.getHeaders()).map(([k, v]) => [k, String(v)]));
77
+ const dumpData = {
78
+ id,
79
+ timestamp,
80
+ durationMs: Date.now() - started,
81
+ method,
82
+ url: req.url ?? '/',
83
+ pathname: url.pathname,
84
+ search: url.search,
85
+ operationId: meta.operationId,
86
+ routePath: meta.routePath,
87
+ pathParams: meta.pathParams,
88
+ query: meta.query ?? Object.fromEntries(url.searchParams),
89
+ requestHeaders: safeHeaders(req),
90
+ requestBody: meta.requestBody,
91
+ status,
92
+ responseHeaders,
93
+ responseBody: meta.responseBody,
94
+ errorKind: meta.errorKind,
95
+ errorMessage: meta.errorMessage,
96
+ stderr: meta.stderr,
97
+ cacheStatus: meta.cacheStatus,
98
+ regenerated: meta.regenerated,
99
+ regenAttempts: meta.regenAttempts,
100
+ regenLimitHit: meta.regenLimitHit,
101
+ regenLimit: opts.limiter?.limit,
102
+ issues: meta.issues,
103
+ note: meta.note,
104
+ };
105
+ let dumpPath = '';
106
+ const shouldDump = Boolean(opts.requestsDir &&
107
+ url.pathname !== '/' &&
108
+ !url.pathname.startsWith('/__faker/') &&
109
+ meta.operationId);
110
+ if (shouldDump) {
111
+ try {
112
+ dumpPath = await saveRequestDump(opts.requestsDir, dumpData);
113
+ }
114
+ catch {
115
+ // Ignore filesystem dump write failures so server remains operational
116
+ }
117
+ }
118
+ if (opts.onRequest) {
119
+ opts.onRequest(formatLogLine(dumpData, dumpPath));
120
+ }
121
+ };
61
122
  if (url.pathname.startsWith('/__faker/')) {
62
123
  introspect(url.pathname, res, opts);
63
- say(200, 'introspection');
124
+ await finish(200, { note: 'introspection' });
64
125
  return;
65
126
  }
66
- const match = opts.router.match(method, url.pathname);
127
+ const match = opts.router.match(method, url.pathname, url.searchParams);
67
128
  if (!match) {
68
- const allowed = opts.router.allowed(url.pathname);
129
+ // The document owns `/` only when it declares it. Otherwise the root is
130
+ // the contents page, which is what a browser pointed here came for.
131
+ if (url.pathname === '/' && (method === 'get' || method === 'head')) {
132
+ const end = prepareHtml(res, indexPage(opts.router.operations));
133
+ await finish(200, { note: 'index' });
134
+ end();
135
+ return;
136
+ }
137
+ // The path matched and the query did not, which no one guesses unaided
138
+ // — and it is not a 405 either, since the method is defined here.
139
+ const expects = opts.router.expects(method, url.pathname);
140
+ if (expects.length > 0) {
141
+ const bodyObj = {
142
+ error: `${req.method} ${url.pathname} is only defined with a query`,
143
+ expects,
144
+ };
145
+ const end = prepareSend(res, 404, bodyObj);
146
+ await finish(404, {
147
+ errorKind: 'CLIENT_ERROR: NOT_FOUND',
148
+ errorMessage: `expects ${expects.join(' ')}`,
149
+ responseBody: bodyObj,
150
+ note: `expects ${expects.join(' ')}`,
151
+ });
152
+ end();
153
+ return;
154
+ }
155
+ const allowed = opts.router.allowed(url.pathname, url.searchParams);
69
156
  if (allowed.length > 0) {
70
157
  res.setHeader('allow', allowed.map((m) => m.toUpperCase()).join(', '));
71
- send(res, 405, { error: `${req.method} is not defined for ${url.pathname}` });
72
- say(405, 'no such method');
158
+ const bodyObj = { error: `${req.method} is not defined for ${url.pathname}` };
159
+ const end = prepareSend(res, 405, bodyObj);
160
+ await finish(405, {
161
+ errorKind: 'CLIENT_ERROR: METHOD_NOT_ALLOWED',
162
+ errorMessage: 'no such method',
163
+ responseBody: bodyObj,
164
+ note: 'no such method',
165
+ });
166
+ end();
73
167
  return;
74
168
  }
75
- send(res, 404, { error: `no operation matches ${req.method} ${url.pathname}` });
76
- say(404, 'no route');
169
+ const bodyObj = {
170
+ error: `no operation matches ${req.method} ${url.pathname}`,
171
+ };
172
+ const end = prepareSend(res, 404, bodyObj);
173
+ await finish(404, {
174
+ errorKind: 'CLIENT_ERROR: NOT_FOUND',
175
+ errorMessage: 'no route',
176
+ responseBody: bodyObj,
177
+ note: 'no route',
178
+ });
179
+ end();
77
180
  return;
78
181
  }
79
182
  const { operation, pathParams } = match;
@@ -84,20 +187,51 @@ async function handle(req, res, opts) {
84
187
  }
85
188
  catch (err) {
86
189
  const message = err instanceof Error ? err.message : String(err);
87
- send(res, message.includes('too large') ? 413 : 400, { error: message });
88
- say(400, 'bad body');
190
+ const status = message.includes('too large') ? 413 : 400;
191
+ const errorKind = status === 413 ? 'CLIENT_ERROR: PAYLOAD_TOO_LARGE' : 'CLIENT_ERROR: BAD_REQUEST';
192
+ const bodyObj = { error: message };
193
+ const end = prepareSend(res, status, bodyObj);
194
+ await finish(status, {
195
+ operationId: operation.operationId,
196
+ routePath: operation.path,
197
+ pathParams,
198
+ errorKind,
199
+ errorMessage: message,
200
+ responseBody: bodyObj,
201
+ note: status === 413 ? 'body too large' : 'bad body',
202
+ });
203
+ end();
89
204
  return;
90
205
  }
91
206
  const problems = check(operation, opts.checks, pathParams, url.searchParams, body);
92
207
  if (problems.length > 0) {
93
- send(res, 400, { error: describeIssues(problems), issues: problems });
94
- say(400, describeIssues(problems));
208
+ const desc = describeIssues(problems);
209
+ const bodyObj = { error: desc, issues: problems };
210
+ const end = prepareSend(res, 400, bodyObj);
211
+ await finish(400, {
212
+ operationId: operation.operationId,
213
+ routePath: operation.path,
214
+ pathParams,
215
+ requestBody: body,
216
+ errorKind: 'CLIENT_ERROR: VALIDATION_FAILED',
217
+ errorMessage: desc,
218
+ issues: problems,
219
+ responseBody: bodyObj,
220
+ note: desc,
221
+ });
222
+ end();
95
223
  return;
96
224
  }
97
225
  if (!operation.success.schema) {
98
226
  res.statusCode = operation.success.status;
227
+ await finish(operation.success.status, {
228
+ operationId: operation.operationId,
229
+ routePath: operation.path,
230
+ pathParams,
231
+ requestBody: body,
232
+ note: 'no body declared',
233
+ });
99
234
  res.end();
100
- say(operation.success.status, 'no body declared');
101
235
  return;
102
236
  }
103
237
  let generator;
@@ -107,15 +241,27 @@ async function handle(req, res, opts) {
107
241
  catch (err) {
108
242
  const status = err instanceof BuildFailed ? 501 : 500;
109
243
  const detail = reason(err);
110
- send(res, status, {
244
+ const bodyObj = {
111
245
  error: `no generator for ${operation.operationId}`,
112
246
  detail,
113
247
  diagnostics: err instanceof BuildFailed ? err.diagnostics : undefined,
248
+ };
249
+ const end = prepareSend(res, status, bodyObj);
250
+ await finish(status, {
251
+ operationId: operation.operationId,
252
+ routePath: operation.path,
253
+ pathParams,
254
+ requestBody: body,
255
+ errorKind: 'GENERATOR_BUILD_FAILED',
256
+ errorMessage: `no generator: ${detail}`,
257
+ responseBody: bodyObj,
258
+ note: `no generator: ${detail}`,
114
259
  });
115
- say(status, `no generator: ${detail}`);
260
+ end();
116
261
  return;
117
262
  }
118
263
  res.setHeader('x-faker-cache', generator.cached ? 'hit' : 'miss');
264
+ let cacheStatus = generator.cached ? 'hit' : 'miss';
119
265
  const input = {
120
266
  operationId: operation.operationId,
121
267
  method: operation.method,
@@ -126,33 +272,179 @@ async function handle(req, res, opts) {
126
272
  body,
127
273
  seed: seedFor(opts.seed, operation, pathParams, url.searchParams),
128
274
  };
129
- const outcome = await opts.box.run(operation.key, input);
275
+ let outcome = await opts.box.run(operation.key, input);
276
+ const responseValidator = opts.checks.for(operation).response;
277
+ let schemaMismatch = false;
278
+ if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
279
+ schemaMismatch = true;
280
+ const desc = describeIssues(issues('', responseValidator.errors));
281
+ outcome = {
282
+ ok: false,
283
+ fault: `output does not match response schema — ${desc}`,
284
+ stderr: outcome.stderr,
285
+ durationMs: outcome.durationMs,
286
+ value: outcome.value,
287
+ };
288
+ }
130
289
  if (!outcome.ok) {
131
- send(res, 502, {
132
- error: `the generator for ${operation.operationId} ${outcome.fault}`,
133
- stderr: outcome.stderr || undefined,
134
- });
135
- say(502, 'generator faulted');
136
- return;
290
+ const urlKey = url.pathname;
291
+ const errorKind = outcome.fault?.includes('longer than')
292
+ ? 'GENERATOR_TIMEOUT'
293
+ : schemaMismatch
294
+ ? 'GENERATOR_SCHEMA_MISMATCH'
295
+ : 'GENERATOR_FAULT';
296
+ // Check regeneration limit for this URL
297
+ if (opts.limiter.isLimitHit(urlKey)) {
298
+ opts.onRequest?.(formatRegenLimitHit(urlKey, opts.limiter.limit, operation.operationId));
299
+ const bodyObj = {
300
+ error: `the generator for ${operation.operationId} ${outcome.fault}`,
301
+ stderr: outcome.stderr || undefined,
302
+ regenerationLimitHit: true,
303
+ regenerationLimit: opts.limiter.limit,
304
+ };
305
+ const end = prepareSend(res, 502, bodyObj);
306
+ await finish(502, {
307
+ operationId: operation.operationId,
308
+ routePath: operation.path,
309
+ pathParams,
310
+ requestBody: body,
311
+ errorKind,
312
+ errorMessage: `${outcome.fault} (regeneration limit reached)`,
313
+ stderr: outcome.stderr,
314
+ responseBody: bodyObj,
315
+ cacheStatus,
316
+ regenLimitHit: true,
317
+ note: 'generator faulted (limit hit)',
318
+ });
319
+ end();
320
+ return;
321
+ }
322
+ const attempt = opts.limiter.increment(urlKey);
323
+ opts.onRequest?.(formatRegenerating(urlKey, attempt, opts.limiter.limit, operation.operationId, outcome.fault));
324
+ const regenStarted = Date.now();
325
+ let currentSource = generator.source;
326
+ if (!currentSource?.trim()) {
327
+ currentSource = (await opts.cache.getSource?.(operation.key)) ?? '';
328
+ }
329
+ if (!currentSource?.trim()) {
330
+ try {
331
+ currentSource = await readFile(opts.box.sourceOf(operation.key), 'utf8');
332
+ }
333
+ catch {
334
+ currentSource = '# generator';
335
+ }
336
+ }
337
+ const examples = opts.history.getSuccessful(operation.key, 2);
338
+ try {
339
+ await opts.cache.regenerate(operation, {
340
+ currentSource,
341
+ failingInput: input,
342
+ fault: outcome.fault ?? 'generator faulted',
343
+ stderr: outcome.stderr,
344
+ examples,
345
+ });
346
+ cacheStatus = 'regenerated';
347
+ res.setHeader('x-faker-cache', 'regenerated');
348
+ opts.onRequest?.(formatRegenerated(urlKey, attempt, Date.now() - regenStarted, operation.operationId));
349
+ // Re-run the request with the newly regenerated generator
350
+ outcome = await opts.box.run(operation.key, input);
351
+ if (outcome.ok && responseValidator && !responseValidator(outcome.value)) {
352
+ outcome = {
353
+ ok: false,
354
+ fault: 'output does not match response schema after regeneration',
355
+ stderr: outcome.stderr,
356
+ durationMs: outcome.durationMs,
357
+ };
358
+ }
359
+ }
360
+ catch (regenErr) {
361
+ const detail = reason(regenErr);
362
+ const bodyObj = {
363
+ error: `regeneration failed for ${operation.operationId}: ${detail}`,
364
+ stderr: outcome.stderr || undefined,
365
+ regenerated: false,
366
+ };
367
+ const end = prepareSend(res, 502, bodyObj);
368
+ await finish(502, {
369
+ operationId: operation.operationId,
370
+ routePath: operation.path,
371
+ pathParams,
372
+ requestBody: body,
373
+ errorKind: 'REGENERATION_FAILED',
374
+ errorMessage: `regeneration failed: ${detail}`,
375
+ stderr: outcome.stderr,
376
+ responseBody: bodyObj,
377
+ cacheStatus,
378
+ regenAttempts: attempt,
379
+ note: 'regeneration failed',
380
+ });
381
+ end();
382
+ return;
383
+ }
384
+ if (!outcome.ok) {
385
+ const bodyObj = {
386
+ error: `the regenerated generator for ${operation.operationId} ${outcome.fault}`,
387
+ stderr: outcome.stderr || undefined,
388
+ regenerated: true,
389
+ };
390
+ const end = prepareSend(res, 502, bodyObj);
391
+ await finish(502, {
392
+ operationId: operation.operationId,
393
+ routePath: operation.path,
394
+ pathParams,
395
+ requestBody: body,
396
+ errorKind,
397
+ errorMessage: `regenerated generator ${outcome.fault}`,
398
+ stderr: outcome.stderr,
399
+ responseBody: bodyObj,
400
+ cacheStatus,
401
+ regenerated: true,
402
+ regenAttempts: attempt,
403
+ note: 'regenerated generator faulted',
404
+ });
405
+ end();
406
+ return;
407
+ }
137
408
  }
138
- const note = generator.cached ? 'hit' : 'miss';
139
- const looped = cut(operation, outcome.value, url.searchParams);
140
- send(res, operation.success.status, outcome.value);
141
- say(operation.success.status, looped ? `${note} · cut a looping page token` : note);
409
+ const note = cacheStatus;
410
+ const streamKey = `${operation.key}:${req.socket.remoteAddress || 'local'}`;
411
+ const cutResult = cut(operation, outcome.value, url.searchParams, body, opts.paginationLimiter, streamKey);
412
+ opts.history.recordSuccess(operation.key, input, outcome.value);
413
+ const end = prepareSend(res, operation.success.status, outcome.value);
414
+ await finish(operation.success.status, {
415
+ operationId: operation.operationId,
416
+ routePath: operation.path,
417
+ pathParams,
418
+ requestBody: body,
419
+ responseBody: outcome.value,
420
+ cacheStatus,
421
+ regenerated: cacheStatus === 'regenerated',
422
+ note: cutResult?.cut ? `${note} · ${cutResult.reason}` : note,
423
+ });
424
+ end();
142
425
  }
143
426
  /**
144
427
  * The generator on disk was written before the pagination rule existed, and a
145
428
  * cache is not rebuilt just because the rule changed. A body offering back the
146
429
  * token it was handed is therefore still possible, and it is the one bug here
147
430
  * that costs the client rather than the mock: it hangs.
431
+ *
432
+ * In addition, pagination is tracked and capped to prevent infinite loops (random
433
+ * 1-10 max pages by default) and repetitive cycling tokens.
148
434
  */
149
- function cut(operation, value, query) {
150
- const paging = operation.paging ?? pagingSeen(operation.params, operation.success.schema, query);
435
+ function cut(operation, value, query, body, limiter, streamKey = 'default') {
436
+ const paging = operation.paging ?? pagingSeen(operation.params, operation.success.schema, query, body);
151
437
  if (!paging) {
152
- return false;
438
+ return undefined;
439
+ }
440
+ const sent = getSentToken(query, body, paging);
441
+ if (limiter) {
442
+ return limiter.process(operation.key, streamKey, sent, value, paging);
153
443
  }
154
- const sent = query.get(paging.param);
155
- return sent !== null && cutLoop(value, paging, sent);
444
+ if (sent !== undefined && cutLoop(value, paging, sent)) {
445
+ return { cut: true, reason: 'cut a looping page token' };
446
+ }
447
+ return undefined;
156
448
  }
157
449
  function check(operation, checks, pathParams, query, body) {
158
450
  const compiled = checks.for(operation);
@@ -264,11 +556,24 @@ function introspect(pathname, res, opts) {
264
556
  }
265
557
  send(res, 404, { error: `no such endpoint: ${pathname}` });
266
558
  }
267
- function send(res, status, value) {
559
+ function prepareSend(res, status, value) {
268
560
  const text = `${JSON.stringify(value, null, 2)}\n`;
269
561
  res.statusCode = status;
270
562
  res.setHeader('content-type', 'application/json; charset=utf-8');
271
563
  res.setHeader('content-length', Buffer.byteLength(text));
272
- res.end(text);
564
+ return () => res.end(text);
565
+ }
566
+ function send(res, status, value) {
567
+ prepareSend(res, status, value)();
568
+ }
569
+ function prepareHtml(res, text) {
570
+ res.statusCode = 200;
571
+ res.setHeader('content-type', 'text/html; charset=utf-8');
572
+ res.setHeader('content-length', Buffer.byteLength(text));
573
+ return () => res.end(text);
574
+ }
575
+ /** Node drops the body itself when the request was a HEAD. */
576
+ function html(res, text) {
577
+ prepareHtml(res, text)();
273
578
  }
274
579
  //# sourceMappingURL=server.js.map
package/dist/setup.d.ts CHANGED
@@ -1,9 +1,26 @@
1
+ import { KeyStore } from '@zenera/cli/lib';
1
2
  import { type Model } from '@zenera/neo';
2
3
  import { Box } from './box.ts';
3
4
  import { Cache, type CacheOptions } from './cache.ts';
4
5
  import { Router } from './router.ts';
5
6
  import { SpecError } from './spec.ts';
6
7
  import { Checks } from './validate.ts';
8
+ /** The variable a shell or a CI job names it with — a zen ref. */
9
+ export declare const MODEL_ENV = "ZENERA_FAKER_MODEL";
10
+ export type ModelSource = 'flag' | 'env' | 'store' | 'default';
11
+ export declare const SOURCE_LABELS: Record<ModelSource, string>;
12
+ export interface ModelChoice {
13
+ ref: string;
14
+ from: ModelSource;
15
+ }
16
+ export interface FakerFile {
17
+ version: 1;
18
+ model?: string;
19
+ }
20
+ export declare const readSettings: () => Promise<FakerFile>;
21
+ export declare function writeSettings(file: FakerFile): void;
22
+ /** What `open` would run, and where it came from, so a command can say so too. */
23
+ export declare function chooseModel(flag: string | undefined, keys: KeyStore): Promise<ModelChoice | undefined>;
7
24
  export interface SetupOptions {
8
25
  specs: readonly string[];
9
26
  cwd: string;
@@ -26,6 +43,7 @@ export interface Setup {
26
43
  cache: Cache;
27
44
  box: Box;
28
45
  model: Model;
46
+ choice: ModelChoice;
29
47
  image: string;
30
48
  root: string;
31
49
  close(): Promise<void>;
package/dist/setup.js CHANGED
@@ -1,7 +1,7 @@
1
- import { credentialError, ensureHome, ensurePodmanReady, envNames, invalidError, KeyStore, paths, PROVIDERS, } from '@zenera/cli/lib';
1
+ import { credentialError, ensureHome, ensurePodmanReady, envNames, home, invalidError, KeyStore, paths, PROVIDERS, readJson, writeJson, } from '@zenera/cli/lib';
2
2
  import { createModel } from '@zenera/neo';
3
3
  import { mkdirSync } from 'node:fs';
4
- import { resolve } from 'node:path';
4
+ import { join, resolve } from 'node:path';
5
5
  import { Box } from "./box.js";
6
6
  import { Cache } from "./cache.js";
7
7
  import { ensureImage } from "./image.js";
@@ -18,17 +18,58 @@ import { Checks } from "./validate.js";
18
18
  // happened to be checked first.
19
19
  // ---------------------------------------------------------------------------
20
20
  /**
21
- * The model each provider gets when none is named. Every ref names its
21
+ * The model each provider gets when none is named — the same picks `zen meta`
22
+ * recommends (`RECOMMENDED` in `packages/cli/src/meta.ts`). Every ref names its
22
23
  * provider: the shorthand reads the first segment as a *provider name*, so a
23
- * bare `gemini-3.5-flash` would be asked of OpenAI.
24
+ * bare `gemini-3.8-flash` would be asked of OpenAI.
24
25
  */
25
26
  const DEFAULT_MODEL = {
26
- openai: 'openai:gpt-5.4-mini',
27
- anthropic: 'anthropic:claude-sonnet-4-5',
28
- google: 'google:gemini-3.5-flash',
29
- vertex: 'vertex:gemini-3.5-flash',
30
- openrouter: 'openrouter:inclusionai/ling-3.0-flash-fin:free',
27
+ openai: 'openai:gpt-5.6-sol',
28
+ anthropic: 'anthropic:claude-opus-5',
29
+ google: 'google:gemini-3.8-flash',
30
+ vertex: 'vertex:gemini-3.8-flash',
31
+ openrouter: 'openrouter:anthropic/claude-opus-5',
31
32
  };
33
+ // ---------------------------------------------------------------------------
34
+ // Which model, and why that one
35
+ //
36
+ // The same chain `zen meta` uses: a flag beats the environment, the environment
37
+ // beats what was stored, and with none of them set it is whatever the keys on
38
+ // hand can buy. Which model writes generators is a choice about a tool on this
39
+ // machine and not about a document, so it is stored beside the keyring rather
40
+ // than anywhere near a specification.
41
+ // ---------------------------------------------------------------------------
42
+ /** The variable a shell or a CI job names it with — a zen ref. */
43
+ export const MODEL_ENV = 'ZENERA_FAKER_MODEL';
44
+ export const SOURCE_LABELS = {
45
+ flag: '--model',
46
+ env: MODEL_ENV,
47
+ store: 'zen faker model',
48
+ default: 'recommended',
49
+ };
50
+ const EMPTY = { version: 1 };
51
+ const settingsPath = () => join(home(), 'faker.json');
52
+ export const readSettings = () => readJson(settingsPath(), EMPTY);
53
+ export function writeSettings(file) {
54
+ ensureHome();
55
+ writeJson(settingsPath(), file);
56
+ }
57
+ /** What `open` would run, and where it came from, so a command can say so too. */
58
+ export async function chooseModel(flag, keys) {
59
+ const chain = [
60
+ ['flag', flag],
61
+ ['env', process.env[MODEL_ENV]],
62
+ ['store', (await readSettings()).model],
63
+ ['default', defaultRef(keys)],
64
+ ];
65
+ for (const [from, ref] of chain) {
66
+ const named = ref?.trim();
67
+ if (named) {
68
+ return { ref: named, from };
69
+ }
70
+ }
71
+ return undefined;
72
+ }
32
73
  export async function open(opts) {
33
74
  if (opts.specs.length === 0) {
34
75
  throw invalidError('no specification given', 'name one or more openapi/swagger files');
@@ -37,7 +78,11 @@ export async function open(opts) {
37
78
  ensureHome();
38
79
  const keys = await KeyStore.open();
39
80
  keys.materialize();
40
- const model = createModel(opts.model ?? defaultRef(keys));
81
+ const choice = await chooseModel(opts.model, keys);
82
+ if (!choice) {
83
+ throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
84
+ }
85
+ const model = createModel(choice.ref);
41
86
  const root = resolve(opts.cwd, opts.cache ?? paths.faker());
42
87
  mkdirSync(root, { recursive: true, mode: 0o700 });
43
88
  // Podman is asked about before the image is built, so a machine without a
@@ -62,7 +107,7 @@ export async function open(opts) {
62
107
  ephemeral: opts.ephemeral,
63
108
  ...opts.events,
64
109
  });
65
- return { router, checks, cache, box, model, image, root, close: () => box.dispose() };
110
+ return { router, checks, cache, box, model, choice, image, root, close: () => box.dispose() };
66
111
  }
67
112
  /**
68
113
  * Presence, not liveness. `zen init` probes because it is writing a project
@@ -72,10 +117,7 @@ export async function open(opts) {
72
117
  function defaultRef(keys) {
73
118
  const provider = PROVIDERS.find((p) => envNames(p).some((name) => process.env[name])) ??
74
119
  PROVIDERS.find((p) => keys.active(p) !== undefined);
75
- if (!provider) {
76
- throw credentialError('no credentials for any provider', 'add one with: zen key add openai');
77
- }
78
- return DEFAULT_MODEL[provider];
120
+ return provider ? DEFAULT_MODEL[provider] : undefined;
79
121
  }
80
122
  export { SpecError };
81
123
  //# sourceMappingURL=setup.js.map
package/dist/spec.d.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import { CliError } from '@zenera/cli/lib';
2
2
  import { type Paging } from './paging.ts';
3
3
  import { type Schema } from './schema.ts';
4
+ /**
5
+ * Version of generator prompt rules and environment contracts.
6
+ * Bumping this invalidates cached Python generators and triggers regeneration.
7
+ */
8
+ export declare const GENERATOR_RULES_VERSION = 2;
4
9
  export declare const METHODS: readonly ["get", "put", "post", "delete", "patch", "head", "options"];
5
10
  export type Method = (typeof METHODS)[number];
6
11
  export type ParamIn = 'path' | 'query' | 'header' | 'cookie';
@@ -23,6 +28,8 @@ export interface Operation {
23
28
  summary?: string;
24
29
  description?: string;
25
30
  params: ParamSpec[];
31
+ /** query values the path key pinned down, see `splitQuery` */
32
+ fixed?: Record<string, string>;
26
33
  requestBody?: {
27
34
  required: boolean;
28
35
  schema: Schema;
@@ -41,4 +48,6 @@ export declare class SpecError extends CliError {
41
48
  }
42
49
  export declare function loadSpecs(files: readonly string[]): Promise<Operation[]>;
43
50
  export declare function loadSpec(file: string): Promise<Operation[]>;
51
+ /** The path as it has to be called — what a person needs to see. */
52
+ export declare const called: (op: Operation) => string;
44
53
  //# sourceMappingURL=spec.d.ts.map