@zenera/cli 1.1.3 → 1.1.5
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 +160 -40
- package/dist/audit.d.ts +5 -2
- package/dist/audit.js +7 -2
- package/dist/catalog.d.ts +111 -0
- package/dist/catalog.js +439 -0
- package/dist/commands/check.js +39 -11
- package/dist/commands/index.d.ts +2 -2
- package/dist/commands/index.js +4 -3
- package/dist/commands/key.js +18 -0
- package/dist/commands/models.d.ts +0 -6
- package/dist/commands/models.js +546 -101
- package/dist/home.d.ts +2 -0
- package/dist/home.js +2 -0
- package/dist/keys.d.ts +9 -1
- package/dist/lib.d.ts +1 -0
- package/dist/lib.js +1 -0
- package/dist/liveness.d.ts +32 -0
- package/dist/liveness.js +194 -5
- package/dist/scaffold.js +9 -0
- package/dist/validate.d.ts +17 -1
- package/dist/validate.js +97 -7
- package/package.json +2 -2
- package/templates/editor/.github/copilot-instructions.md +30 -3
- package/templates/editor/.github/skills/api-schema-index/SKILL.md +130 -9
- package/templates/editor/.github/skills/zen-cli/SKILL.md +7 -4
- package/templates/editor/.github/skills/zen-cli/references/check.md +15 -19
- package/templates/editor/.github/skills/zen-cli/references/keys.md +5 -0
- package/templates/editor/.github/skills/zen-cli/references/models.md +108 -0
- package/templates/editor/.github/skills/zen-cli/references/rag.md +65 -3
- package/templates/project/sandbox/{Dockerfile → Dockerfile.tmpl} +3 -1
package/dist/commands/models.js
CHANGED
|
@@ -1,120 +1,565 @@
|
|
|
1
|
-
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// zen models
|
|
3
|
+
//
|
|
4
|
+
// What this machine *can* use, as opposed to whether one particular project
|
|
5
|
+
// works. `zen check` answers the second question — it resolves a project,
|
|
6
|
+
// audits its credentials and asks every model it declares one real question.
|
|
7
|
+
// This command answers the first, and needs no project at all.
|
|
8
|
+
//
|
|
9
|
+
// The split matters most when something breaks. `zen check` says "this
|
|
10
|
+
// embedder was refused"; `zen models pick --embedding` says "use this one
|
|
11
|
+
// instead", and prints a ref on stdout that can be pasted straight back into
|
|
12
|
+
// agents.yaml — or read by an agent doing the pasting.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
import { createEmbedder, createModel, ModelRegistry } from '@zenera/neo';
|
|
2
15
|
import { parse } from "../args.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
16
|
+
import { loadCatalog, loadCatalogs, matches, PREFERRED, } from "../catalog.js";
|
|
17
|
+
import { ensureHome } from "../home.js";
|
|
18
|
+
import { envNames, form, isProvider, KeyStore, PROVIDERS } from "../keys.js";
|
|
19
|
+
import { probeModel } from "../liveness.js";
|
|
20
|
+
import { ago, bold, credentialError, cyan, dim, green, json, note, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
21
|
+
const USAGE = 'zen models <providers|ls|search|show|test|pick> [ref] [options]';
|
|
8
22
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
23
|
+
* The keyring is materialised here, and only here.
|
|
24
|
+
*
|
|
25
|
+
* The frame does not do it, so every command that needs a credential asks for
|
|
26
|
+
* one itself. Forgetting looks exactly like a missing key, which is the most
|
|
27
|
+
* expensive kind of bug this file could have.
|
|
13
28
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
async function credentials() {
|
|
30
|
+
ensureHome();
|
|
31
|
+
const store = await KeyStore.open();
|
|
32
|
+
// Asked before materialising, because materialising is what erases the
|
|
33
|
+
// difference between "the environment had it" and "the keyring supplied it".
|
|
34
|
+
const fromEnv = new Set(PROVIDERS.filter((p) => envNames(p).some((n) => process.env[n])));
|
|
35
|
+
store.materialize();
|
|
36
|
+
const usable = [
|
|
37
|
+
...PROVIDERS.filter((p) => fromEnv.has(p)),
|
|
38
|
+
...PROVIDERS.filter((p) => !fromEnv.has(p) && Boolean(store.active(p))),
|
|
39
|
+
];
|
|
40
|
+
return { store, fromEnv, usable };
|
|
41
|
+
}
|
|
42
|
+
function source(where, provider) {
|
|
43
|
+
if (where.fromEnv.has(provider)) {
|
|
44
|
+
return 'environment';
|
|
45
|
+
}
|
|
46
|
+
return where.store.active(provider) ? 'keyring' : '';
|
|
47
|
+
}
|
|
48
|
+
/** The providers a subcommand should touch, given an optional `--provider`. */
|
|
49
|
+
function scope(where, only) {
|
|
50
|
+
if (!only) {
|
|
51
|
+
if (where.usable.length === 0) {
|
|
52
|
+
throw credentialError('no provider on this machine has a credential', 'try: zen key add openai');
|
|
36
53
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
return where.usable;
|
|
55
|
+
}
|
|
56
|
+
if (!isProvider(only)) {
|
|
57
|
+
throw usageError(`unknown provider "${only}"`, `known: ${PROVIDERS.join(', ')}`);
|
|
58
|
+
}
|
|
59
|
+
return [only];
|
|
60
|
+
}
|
|
61
|
+
const ROLE_OPTIONS = {
|
|
62
|
+
chat: { type: 'boolean' },
|
|
63
|
+
// Both spellings, because half the flags in this CLI read as a filter over
|
|
64
|
+
// a set and half as a choice of one, and nobody should have to remember
|
|
65
|
+
// which this is.
|
|
66
|
+
embedding: { type: 'boolean' },
|
|
67
|
+
embeddings: { type: 'boolean' },
|
|
68
|
+
images: { type: 'boolean' },
|
|
69
|
+
audio: { type: 'boolean' },
|
|
70
|
+
};
|
|
71
|
+
function rolesFrom(values) {
|
|
72
|
+
const roles = [];
|
|
73
|
+
if (values.chat) {
|
|
74
|
+
roles.push('chat');
|
|
75
|
+
}
|
|
76
|
+
if (values.embedding || values.embeddings) {
|
|
77
|
+
roles.push('embedding');
|
|
78
|
+
}
|
|
79
|
+
if (values.images) {
|
|
80
|
+
roles.push('image');
|
|
81
|
+
}
|
|
82
|
+
if (values.audio) {
|
|
83
|
+
roles.push('audio');
|
|
84
|
+
}
|
|
85
|
+
return roles;
|
|
86
|
+
}
|
|
87
|
+
const num = (value, what) => {
|
|
88
|
+
if (value === undefined) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
const n = Number(value);
|
|
92
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
93
|
+
throw usageError(`${what} must be a number, got "${value}"`);
|
|
94
|
+
}
|
|
95
|
+
return n;
|
|
96
|
+
};
|
|
97
|
+
const ms = (n) => (n < 1000 ? `${n}ms` : `${(n / 1000).toFixed(1)}s`);
|
|
98
|
+
const tokens = (n) => {
|
|
99
|
+
if (n === undefined) {
|
|
100
|
+
return '';
|
|
101
|
+
}
|
|
102
|
+
return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
103
|
+
};
|
|
104
|
+
/** Freshness of a listing, said the way a person would ask about it. */
|
|
105
|
+
function freshness(cat) {
|
|
106
|
+
if (cat.origin === 'curated') {
|
|
107
|
+
return yellow('built-in list');
|
|
108
|
+
}
|
|
109
|
+
if (cat.origin === 'stale') {
|
|
110
|
+
return yellow(`stale, ${ago(cat.fetchedAt)}`);
|
|
111
|
+
}
|
|
112
|
+
return cat.origin === 'live' ? green('fetched now') : dim(`cached ${ago(cat.fetchedAt)}`);
|
|
113
|
+
}
|
|
114
|
+
const roleMark = (row) => row.roles.join('+');
|
|
115
|
+
function row(entry) {
|
|
116
|
+
return [
|
|
117
|
+
cyan(entry.ref),
|
|
118
|
+
dim(roleMark(entry)),
|
|
119
|
+
dim(tokens(entry.contextLength)),
|
|
120
|
+
dim(entry.pricing?.free ? 'free' : ''),
|
|
121
|
+
dim(entry.name ?? ''),
|
|
122
|
+
];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Says how a listing was obtained whenever it was not the vendor's own word —
|
|
126
|
+
* on stderr, because it is narration about the answer rather than the answer.
|
|
127
|
+
*/
|
|
128
|
+
function explain(cats) {
|
|
129
|
+
for (const cat of cats) {
|
|
130
|
+
if (cat.problem) {
|
|
131
|
+
note(`${yellow(cat.provider)} could not be listed: ${cat.problem.detail ?? 'no reason given'} ` +
|
|
132
|
+
dim(`(showing the ${cat.origin} list)`));
|
|
133
|
+
if (cat.problem.fix) {
|
|
134
|
+
note(dim(` ${cat.problem.fix}`));
|
|
44
135
|
}
|
|
45
|
-
throw invalidError(message, dir);
|
|
46
136
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// providers
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
/**
|
|
143
|
+
* The overview, and deliberately an offline one: five listing round trips is
|
|
144
|
+
* the wrong price for the question "what have I got set up". `ls` fetches.
|
|
145
|
+
*/
|
|
146
|
+
const providers = async (ctx, args) => {
|
|
147
|
+
const { values } = parse(args, { refresh: { type: 'boolean' } }, 'zen models providers [--refresh]');
|
|
148
|
+
const where = await credentials();
|
|
149
|
+
const cats = await loadCatalogs(PROVIDERS, {
|
|
150
|
+
offline: !values.refresh,
|
|
151
|
+
refresh: values.refresh,
|
|
152
|
+
});
|
|
153
|
+
const by = new Map(cats.map((c) => [c.provider, c]));
|
|
154
|
+
if (ctx.json) {
|
|
155
|
+
json(PROVIDERS.map((p) => ({
|
|
66
156
|
provider: p,
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
157
|
+
credential: source(where, p) || null,
|
|
158
|
+
env: form(p).env,
|
|
159
|
+
models: by.get(p)?.entries.length ?? 0,
|
|
160
|
+
origin: by.get(p)?.origin,
|
|
161
|
+
fetchedAt: by.get(p)?.fetchedAt,
|
|
162
|
+
})));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
writeAll(table(PROVIDERS.map((p) => {
|
|
166
|
+
const cat = by.get(p);
|
|
167
|
+
const cred = source(where, p);
|
|
168
|
+
const counted = `${cat.entries.length} model${cat.entries.length === 1 ? '' : 's'}`;
|
|
169
|
+
return [
|
|
170
|
+
cred ? cyan(p) : dim(p),
|
|
171
|
+
cred ? green(cred) : red('no credential'),
|
|
172
|
+
cred ? dim(counted) : dim(''),
|
|
173
|
+
cred ? freshness(cat) : dim(`zen key add ${p}`),
|
|
174
|
+
];
|
|
175
|
+
})));
|
|
176
|
+
note('');
|
|
177
|
+
note(dim('zen models ls <provider> what one of them serves'));
|
|
178
|
+
note(dim('zen models pick --chat the first one that answers'));
|
|
179
|
+
};
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// ls
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
const ls = async (ctx, args) => {
|
|
184
|
+
const { values, positionals } = parse(args, {
|
|
185
|
+
...ROLE_OPTIONS,
|
|
186
|
+
refresh: { type: 'boolean' },
|
|
187
|
+
limit: { type: 'string' },
|
|
188
|
+
all: { type: 'boolean' },
|
|
189
|
+
}, 'zen models ls [provider] [--chat] [--embeddings] [--refresh] [--limit N] [--all]');
|
|
190
|
+
const where = await credentials();
|
|
191
|
+
const targets = scope(where, positionals[0]);
|
|
192
|
+
const roles = rolesFrom(values);
|
|
193
|
+
const limit = values.all ? Infinity : (num(values.limit, '--limit') ?? 40);
|
|
194
|
+
const bar = ctx.json ? undefined : progress();
|
|
195
|
+
bar?.update(dim(`listing ${targets.join(', ')} …`));
|
|
196
|
+
const cats = await loadCatalogs(targets, { refresh: values.refresh });
|
|
197
|
+
bar?.done();
|
|
198
|
+
const all = cats
|
|
199
|
+
.flatMap((c) => c.entries)
|
|
200
|
+
.filter((e) => matches(e, '', { roles }))
|
|
201
|
+
.sort((a, b) => a.ref.localeCompare(b.ref));
|
|
202
|
+
if (ctx.json) {
|
|
203
|
+
json({
|
|
204
|
+
models: all,
|
|
205
|
+
sources: cats.map(({ provider, origin, fetchedAt }) => ({
|
|
206
|
+
provider,
|
|
207
|
+
origin,
|
|
208
|
+
fetchedAt,
|
|
209
|
+
})),
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
explain(cats);
|
|
214
|
+
if (all.length === 0) {
|
|
215
|
+
note(dim('nothing matched — try: zen models ls --refresh'));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
writeAll(table(all.slice(0, limit).map(row)));
|
|
219
|
+
if (all.length > limit) {
|
|
220
|
+
note(dim(`${all.length - limit} more — narrow with \`zen models search\`, or pass --all`));
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// search
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
const search = async (ctx, args) => {
|
|
227
|
+
const { values, positionals } = parse(args, {
|
|
228
|
+
...ROLE_OPTIONS,
|
|
229
|
+
provider: { type: 'string' },
|
|
230
|
+
tools: { type: 'boolean' },
|
|
231
|
+
vision: { type: 'boolean' },
|
|
232
|
+
free: { type: 'boolean' },
|
|
233
|
+
'min-context': { type: 'string' },
|
|
234
|
+
limit: { type: 'string' },
|
|
235
|
+
refresh: { type: 'boolean' },
|
|
236
|
+
}, 'zen models search <query> [--provider p] [--embeddings] [--tools] [--vision] [--free] [--min-context N] [--limit N]');
|
|
237
|
+
const query = positionals.join(' ').trim();
|
|
238
|
+
const where = await credentials();
|
|
239
|
+
const targets = scope(where, values.provider);
|
|
240
|
+
const limit = num(values.limit, '--limit') ?? 20;
|
|
241
|
+
const filters = {
|
|
242
|
+
roles: rolesFrom(values),
|
|
243
|
+
tools: values.tools,
|
|
244
|
+
vision: values.vision,
|
|
245
|
+
free: values.free,
|
|
246
|
+
minContext: num(values['min-context'], '--min-context'),
|
|
247
|
+
};
|
|
248
|
+
const bar = ctx.json ? undefined : progress();
|
|
249
|
+
bar?.update(dim(`searching ${targets.join(', ')} …`));
|
|
250
|
+
const cats = await loadCatalogs(targets, { refresh: values.refresh });
|
|
251
|
+
bar?.done();
|
|
252
|
+
const hits = cats
|
|
253
|
+
.flatMap((c) => c.entries)
|
|
254
|
+
.filter((e) => matches(e, query, filters))
|
|
255
|
+
// Cheapest first when price is known: search is usually the step before
|
|
256
|
+
// picking one, and the free ones are what most people are looking for.
|
|
257
|
+
.sort((a, b) => Number(b.pricing?.free ?? false) - Number(a.pricing?.free ?? false) ||
|
|
258
|
+
a.ref.localeCompare(b.ref));
|
|
259
|
+
if (ctx.json) {
|
|
260
|
+
json({ query, matched: hits.length, models: hits.slice(0, limit) });
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
explain(cats);
|
|
264
|
+
if (hits.length === 0) {
|
|
265
|
+
note(dim(`nothing matched "${query}" — try fewer words, or --refresh`));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
writeAll(table(hits.slice(0, limit).map(row)));
|
|
269
|
+
if (hits.length > limit) {
|
|
270
|
+
note(dim(`${hits.length - limit} more — raise --limit, or add a word`));
|
|
271
|
+
}
|
|
272
|
+
note(dim('zen models test <ref> ask one of them whether it answers'));
|
|
273
|
+
};
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// show
|
|
276
|
+
// ---------------------------------------------------------------------------
|
|
277
|
+
/**
|
|
278
|
+
* Splits a ref without building anything. `ModelRegistry.parse` is the same
|
|
279
|
+
* splitter the runtime uses, so a ref that reads here reads there — and a typo
|
|
280
|
+
* is a usage error rather than a credential one.
|
|
281
|
+
*/
|
|
282
|
+
function split(ref) {
|
|
283
|
+
const registry = new ModelRegistry();
|
|
284
|
+
let spec;
|
|
285
|
+
try {
|
|
286
|
+
spec = registry.parse(ref);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
throw usageError(err instanceof Error ? err.message.split('\n')[0] : `bad reference "${ref}"`, 'expected provider:model — see: zen models ls');
|
|
290
|
+
}
|
|
291
|
+
const provider = spec.provider ?? registry.defaultProvider;
|
|
292
|
+
if (!isProvider(provider)) {
|
|
293
|
+
throw usageError(`"${provider}" is not a provider this command knows`, `known: ${PROVIDERS.join(', ')}`);
|
|
294
|
+
}
|
|
295
|
+
return { provider, id: spec.model };
|
|
296
|
+
}
|
|
297
|
+
const show = async (ctx, args) => {
|
|
298
|
+
const { values, positionals } = parse(args, { refresh: { type: 'boolean' } }, 'zen models show <provider:model> [--refresh]');
|
|
299
|
+
const ref = positionals[0];
|
|
300
|
+
if (!ref) {
|
|
301
|
+
throw usageError('which model?', 'see: zen models ls');
|
|
302
|
+
}
|
|
303
|
+
const { provider, id } = split(ref);
|
|
304
|
+
const cat = await loadCatalog(provider, { refresh: values.refresh });
|
|
305
|
+
const found = cat.entries.find((e) => e.id === id);
|
|
306
|
+
if (ctx.json) {
|
|
307
|
+
json(found ?? { ref: `${provider}:${id}`, provider, id, known: false });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
explain([cat]);
|
|
311
|
+
if (!found) {
|
|
312
|
+
note(`${yellow('not listed')} ${provider} does not advertise ${bold(id)}`);
|
|
313
|
+
note(dim(`it may still work — try: zen models test ${provider}:${id}`));
|
|
314
|
+
note(dim(`or look: zen models search ${id} --provider ${provider}`));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const rows = [
|
|
318
|
+
[dim('ref'), cyan(found.ref)],
|
|
319
|
+
[dim('roles'), found.roles.join(', ')],
|
|
320
|
+
];
|
|
321
|
+
const add = (label, value) => {
|
|
322
|
+
if (value) {
|
|
323
|
+
rows.push([dim(label), value]);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
add('name', found.name);
|
|
327
|
+
add('context', found.contextLength ? `${found.contextLength.toLocaleString()} tokens` : undefined);
|
|
328
|
+
add('max output', found.maxOutputTokens ? `${found.maxOutputTokens.toLocaleString()} tokens` : undefined);
|
|
329
|
+
add('dimensions', found.dimensions ? String(found.dimensions) : undefined);
|
|
330
|
+
add('input', found.modalities?.input?.join(', '));
|
|
331
|
+
add('output', found.modalities?.output?.join(', '));
|
|
332
|
+
add('supports', Object.entries(found.supports ?? {})
|
|
333
|
+
.filter(([, on]) => on)
|
|
334
|
+
.map(([k]) => k)
|
|
335
|
+
.join(', '));
|
|
336
|
+
add('pricing', found.pricing?.free
|
|
337
|
+
? 'free'
|
|
338
|
+
: found.pricing?.prompt
|
|
339
|
+
? `$${found.pricing.prompt}/token in, $${found.pricing.completion ?? '?'}/token out`
|
|
340
|
+
: undefined);
|
|
341
|
+
add('released', found.created);
|
|
342
|
+
add('source', found.source === 'live' ? `${provider}, ${freshness(cat)}` : 'built-in list');
|
|
343
|
+
writeAll(table(rows));
|
|
344
|
+
if (found.description) {
|
|
345
|
+
note('');
|
|
346
|
+
note(dim(found.description));
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// test
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
/**
|
|
353
|
+
* Which role to ask in. An explicit flag wins; then what the provider says the
|
|
354
|
+
* model is for; then the id, which is the last resort and the only one that can
|
|
355
|
+
* be wrong.
|
|
356
|
+
*/
|
|
357
|
+
async function roleOf(provider, id, forced) {
|
|
358
|
+
if (forced) {
|
|
359
|
+
return forced;
|
|
360
|
+
}
|
|
361
|
+
// Offline: this is one lookup on the way to a real call, and it must not
|
|
362
|
+
// add a listing round trip to every `test`.
|
|
363
|
+
const cat = await loadCatalog(provider, { offline: true });
|
|
364
|
+
const known = cat.entries.find((e) => e.id === id);
|
|
365
|
+
if (known?.roles.includes('embedding') && !known.roles.includes('chat')) {
|
|
366
|
+
return 'embedding';
|
|
367
|
+
}
|
|
368
|
+
if (known?.roles.includes('chat')) {
|
|
369
|
+
return 'chat';
|
|
370
|
+
}
|
|
371
|
+
return /embed/.test(id) ? 'embedding' : 'chat';
|
|
372
|
+
}
|
|
373
|
+
function target(ref, provider, id, role) {
|
|
374
|
+
if (role === 'embedding') {
|
|
375
|
+
return { ref, kind: 'embedding', embedder: createEmbedder({ provider, model: id }) };
|
|
376
|
+
}
|
|
377
|
+
if (role !== 'chat') {
|
|
378
|
+
throw usageError(`${ref} is ${role}-only, and there is no way to ask it a question from here`, 'zen models test only exercises chat and embedding models');
|
|
379
|
+
}
|
|
380
|
+
// 16 tokens is enough for "ok" and not enough to matter. Anthropic requires
|
|
381
|
+
// a cap at all, so this is not merely thrift.
|
|
382
|
+
return { ref, kind: 'model', model: createModel({ provider, model: id, maxTokens: 16 }) };
|
|
383
|
+
}
|
|
384
|
+
function verdict(probe) {
|
|
385
|
+
switch (probe.check.state) {
|
|
386
|
+
case 'live':
|
|
387
|
+
return `${green('answers')} ${dim(ms(probe.ms))}`;
|
|
388
|
+
case 'blocked':
|
|
389
|
+
return yellow('blocked');
|
|
390
|
+
case 'dead':
|
|
391
|
+
return red('refused');
|
|
392
|
+
default:
|
|
393
|
+
return dim('no answer');
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const test = async (ctx, args) => {
|
|
397
|
+
const { values, positionals } = parse(args, ROLE_OPTIONS, 'zen models test <provider:model> … [--chat|--embedding]');
|
|
398
|
+
if (positionals.length === 0) {
|
|
399
|
+
throw usageError('which model?', 'see: zen models ls');
|
|
400
|
+
}
|
|
401
|
+
const forced = rolesFrom(values)[0];
|
|
402
|
+
// Every ref is split before anything is built, so a typo in the third one
|
|
403
|
+
// does not arrive after two billable calls.
|
|
404
|
+
const parsed = positionals.map((ref) => ({ ref, ...split(ref) }));
|
|
405
|
+
await credentials();
|
|
406
|
+
const probes = [];
|
|
407
|
+
const bar = ctx.json ? undefined : progress();
|
|
408
|
+
for (const { ref, provider, id } of parsed) {
|
|
409
|
+
bar?.update(dim(`asking ${ref} …`));
|
|
410
|
+
probes.push(await probeModel(target(ref, provider, id, await roleOf(provider, id, forced))));
|
|
411
|
+
}
|
|
412
|
+
bar?.done();
|
|
413
|
+
if (ctx.json) {
|
|
414
|
+
json(probes);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
writeAll(table(probes.map((p) => [
|
|
418
|
+
cyan(p.ref),
|
|
419
|
+
verdict(p),
|
|
420
|
+
dim(p.dimensions ? `${p.dimensions} dims` : ''),
|
|
421
|
+
dim(p.check.detail ?? ''),
|
|
422
|
+
])));
|
|
423
|
+
for (const p of probes) {
|
|
424
|
+
if (p.check.fix) {
|
|
425
|
+
note(dim(`${p.ref}: ${p.check.fix}`));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const failed = probes.filter((p) => p.check.state !== 'live');
|
|
430
|
+
if (failed.length > 0) {
|
|
431
|
+
// The suggestion follows what was actually asked, not what was flagged:
|
|
432
|
+
// being told to `pick --chat` after an embedder was refused is how a
|
|
433
|
+
// recovery path stops being one.
|
|
434
|
+
const role = failed.every((p) => p.kind === 'embedding') ? '--embedding' : '--chat';
|
|
435
|
+
throw credentialError(`${failed.length} of ${probes.length} did not answer`, `find one that does: zen models pick ${role}`);
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// pick
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
/**
|
|
442
|
+
* The recovery path, and the reason this command exists.
|
|
443
|
+
*
|
|
444
|
+
* Candidates are tried one at a time and the walk stops at the first that
|
|
445
|
+
* answers. Sequential on purpose: the goal is *one* working ref, and firing
|
|
446
|
+
* eight billable calls to find it is the wrong trade — especially for the
|
|
447
|
+
* caller most likely to be running this, which is an agent that has just been
|
|
448
|
+
* refused and is looking for somewhere else to go.
|
|
449
|
+
*/
|
|
450
|
+
const pick = async (ctx, args) => {
|
|
451
|
+
const { values } = parse(args, { ...ROLE_OPTIONS, provider: { type: 'string' }, limit: { type: 'string' } }, 'zen models pick --chat|--embedding [--provider p] [--limit N]');
|
|
452
|
+
const roles = rolesFrom(values);
|
|
453
|
+
if (roles.length !== 1 || (roles[0] !== 'chat' && roles[0] !== 'embedding')) {
|
|
454
|
+
throw usageError('which kind of model?', 'pass --chat or --embedding');
|
|
455
|
+
}
|
|
456
|
+
const role = roles[0];
|
|
457
|
+
const where = await credentials();
|
|
458
|
+
const targets = scope(where, values.provider);
|
|
459
|
+
const cap = num(values.limit, '--limit') ?? 8;
|
|
460
|
+
const candidates = targets
|
|
461
|
+
.flatMap((p) => PREFERRED[p][role].map((id) => ({ provider: p, id, ref: `${p}:${id}` })))
|
|
462
|
+
.slice(0, cap);
|
|
463
|
+
if (candidates.length === 0) {
|
|
464
|
+
throw credentialError(`no ${role} model is known for ${targets.join(', ')}`, 'try: zen models pick --provider openai');
|
|
465
|
+
}
|
|
466
|
+
const tried = [];
|
|
467
|
+
const bar = ctx.json ? undefined : progress();
|
|
468
|
+
for (const candidate of candidates) {
|
|
469
|
+
bar?.update(dim(`trying ${candidate.ref} …`));
|
|
470
|
+
const probe = await probeModel(target(candidate.ref, candidate.provider, candidate.id, role));
|
|
471
|
+
tried.push(probe);
|
|
472
|
+
if (probe.check.state !== 'live') {
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
bar?.done();
|
|
74
476
|
if (ctx.json) {
|
|
75
477
|
json({
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
478
|
+
ref: probe.ref,
|
|
479
|
+
provider: candidate.provider,
|
|
480
|
+
model: candidate.id,
|
|
481
|
+
role,
|
|
482
|
+
...(probe.dimensions ? { dimensions: probe.dimensions } : {}),
|
|
483
|
+
ms: probe.ms,
|
|
484
|
+
tried: tried.map(({ ref, check }) => ({ ref, ...check })),
|
|
82
485
|
});
|
|
83
486
|
return;
|
|
84
487
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
note(bold('Agents'));
|
|
88
|
-
writeAll(table(agents.map((a) => [
|
|
89
|
-
` ${a.entry ? green('→') : ' '} ${a.name}`,
|
|
90
|
-
cyan(a.model ?? dim('inherited')),
|
|
91
|
-
dim(a.tools.length ? a.tools.join(' ') : 'no tools'),
|
|
92
|
-
dim(a.handoffs.length ? `→ ${a.handoffs.join(', ')}` : ''),
|
|
93
|
-
])));
|
|
94
|
-
if (embeddings.length) {
|
|
95
|
-
note('');
|
|
96
|
-
note(bold('Embeddings'));
|
|
97
|
-
writeAll(table(embeddings.map((e) => [
|
|
98
|
-
` ${e.default ? green('→') : ' '} ${e.name}`,
|
|
99
|
-
cyan(e.model ?? dim('unresolved')),
|
|
100
|
-
])));
|
|
488
|
+
for (const t of tried.slice(0, -1)) {
|
|
489
|
+
note(`${dim(t.ref)} ${verdict(t)} ${dim(t.check.detail ?? '')}`);
|
|
101
490
|
}
|
|
102
|
-
note('');
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
491
|
+
note(`${green('works')} ${bold(probe.ref)} ${dim(ms(probe.ms))}`);
|
|
492
|
+
// stdout, alone and unstyled, so `$(zen models pick --embedding)` is
|
|
493
|
+
// the ref and nothing else.
|
|
494
|
+
write(probe.ref);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
bar?.done();
|
|
498
|
+
if (ctx.json) {
|
|
499
|
+
json({ ref: null, role, tried: tried.map(({ ref, check }) => ({ ref, ...check })) });
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
writeAll(table(tried.map((t) => [
|
|
503
|
+
dim(t.ref),
|
|
504
|
+
verdict(t),
|
|
505
|
+
dim(t.check.fix ?? t.check.detail ?? ''),
|
|
108
506
|
])));
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
507
|
+
}
|
|
508
|
+
throw credentialError(`no ${role} model answered on this machine`, 'add a credential: zen key add openai');
|
|
509
|
+
};
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// Dispatch
|
|
512
|
+
// ---------------------------------------------------------------------------
|
|
513
|
+
const SUBS = {
|
|
514
|
+
providers,
|
|
515
|
+
ls,
|
|
516
|
+
list: ls,
|
|
517
|
+
search: search,
|
|
518
|
+
find: search,
|
|
519
|
+
show: show,
|
|
520
|
+
test,
|
|
521
|
+
check: test,
|
|
522
|
+
pick,
|
|
523
|
+
};
|
|
524
|
+
export const models = {
|
|
525
|
+
summary: 'What this machine can use: list, search, test and pick models.',
|
|
526
|
+
usage: USAGE,
|
|
527
|
+
details: [
|
|
528
|
+
'Answers "what can I use", using the credentials already on this',
|
|
529
|
+
'machine. `zen check` answers the other question — whether one',
|
|
530
|
+
'particular project works — and needs a project to do it.',
|
|
531
|
+
'',
|
|
532
|
+
'Listings come from the providers themselves and are cached for a day',
|
|
533
|
+
'in ~/.zenera/neo/catalog. When a provider cannot be asked, the last',
|
|
534
|
+
'listing is used and said to be stale; only if there was never one does',
|
|
535
|
+
'a short built-in list stand in.',
|
|
536
|
+
'',
|
|
537
|
+
' zen models Providers, credentials and counts.',
|
|
538
|
+
' zen models <provider> Short for `ls <provider>`.',
|
|
539
|
+
' zen models ls [provider] Everything it serves.',
|
|
540
|
+
' zen models search <query> Narrow it — --tools, --vision, --free.',
|
|
541
|
+
' zen models show <ref> One model, in full.',
|
|
542
|
+
' zen models test <ref> … Ask it one real question.',
|
|
543
|
+
' zen models pick --embedding The first ref that answers, on stdout.',
|
|
544
|
+
],
|
|
545
|
+
run: async (ctx) => {
|
|
546
|
+
const [name, ...rest] = ctx.args;
|
|
547
|
+
if (!name) {
|
|
548
|
+
await providers(ctx, []);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const sub = SUBS[name];
|
|
552
|
+
if (sub) {
|
|
553
|
+
await sub(ctx, rest);
|
|
554
|
+
return;
|
|
113
555
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
556
|
+
// `zen models openai` is the thing people type, and it means `ls`.
|
|
557
|
+
// Safe because no provider is named after a subcommand.
|
|
558
|
+
if (isProvider(name)) {
|
|
559
|
+
await ls(ctx, ctx.args);
|
|
560
|
+
return;
|
|
117
561
|
}
|
|
562
|
+
throw usageError(`unknown: zen models ${name}`, `try: ${cyan('providers, ls, search, show, test, pick')} — or a provider: ${dim(PROVIDERS.join(', '))}`);
|
|
118
563
|
},
|
|
119
564
|
};
|
|
120
565
|
//# sourceMappingURL=models.js.map
|