@zenera/cli 1.1.31 → 1.1.33
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 +17 -2
- package/dist/catalog.d.ts +8 -3
- package/dist/catalog.js +49 -9
- package/dist/commands/meta.js +30 -18
- package/dist/commands/models.js +82 -21
- package/dist/keys.d.ts +6 -1
- package/dist/keys.js +6 -1
- package/dist/meta/host.d.ts +3 -1
- package/dist/meta/host.js +3 -1
- package/dist/meta/model.d.ts +19 -2
- package/dist/meta/model.js +31 -4
- package/dist/meta/wire.d.ts +9 -0
- package/dist/meta/wire.js +30 -0
- package/dist/rank.d.ts +24 -0
- package/dist/rank.js +153 -0
- package/package.json +3 -3
- package/templates/editor/.github/skills/zen-cli/references/models.md +7 -0
package/README.md
CHANGED
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
[](https://www.npmjs.com/package/@zenera/cli)
|
|
15
15
|
[](https://github.com/andreyryabov/ZeneraNeo/blob/main/LICENSE)
|
|
16
16
|
[](https://nodejs.org)
|
|
17
|
+
[](https://github.com/andreyryabov/ZeneraNeo/actions/workflows/ci.yml)
|
|
18
|
+
|
|
19
|
+
[Install](#install) · [Quickstart](#quickstart) · [Commands](#commands) · [Documentation](#documentation) · [Family](#the-rest-of-the-family) · [License](#license)
|
|
17
20
|
|
|
18
21
|
</div>
|
|
19
22
|
|
|
@@ -732,7 +735,8 @@ Highest first: `--model`, `ZENERA_META_MODEL` in the shell, the same variable in
|
|
|
732
735
|
the project's `.env`, `zen meta model`, then the project's `agents.yaml`
|
|
733
736
|
`model:`. When none of them answers, a key does: the best model zen knows of
|
|
734
737
|
for the first provider you hold one for, named on stderr as it starts, so a
|
|
735
|
-
fresh machine needs a key and nothing else.
|
|
738
|
+
fresh machine needs a key and nothing else. Through a gateway it is the best
|
|
739
|
+
chat model the gateway serves instead, as `zen models ls --chat --best` ranks it.
|
|
736
740
|
The store is separate from `agents.yaml` on purpose - the model a coding agent
|
|
737
741
|
runs on is a personal choice about a tool, like a key, where `agents.yaml` is a
|
|
738
742
|
committed decision about the project's own agents.
|
|
@@ -740,7 +744,8 @@ committed decision about the project's own agents.
|
|
|
740
744
|
Not every model works. The meta agent declares its tools in a form only the
|
|
741
745
|
reasoning APIs accept, so use a reasoning model - `gpt-5.6-sol`,
|
|
742
746
|
`claude-opus-5`, `gemini-3.8-flash`. Those three are also what it falls back
|
|
743
|
-
to, and what `zen meta model --pick` offers.
|
|
747
|
+
to, and what `zen meta model --pick` offers. `zen meta model` refuses the ones
|
|
748
|
+
that cannot (gpt-4o and older) before asking them anything.
|
|
744
749
|
|
|
745
750
|
Setting one asks it a one-word question before storing it, so a model that will
|
|
746
751
|
not answer is refused with the provider's own words rather than at the start of
|
|
@@ -1125,6 +1130,16 @@ terminal:
|
|
|
1125
1130
|
- [docs/gateway.md](https://github.com/andreyryabov/ZeneraNeo/blob/main/docs/gateway.md)
|
|
1126
1131
|
- every model through a Bifrost gateway, with one virtual key.
|
|
1127
1132
|
|
|
1133
|
+
## The rest of the family
|
|
1134
|
+
|
|
1135
|
+
| Package | What it is |
|
|
1136
|
+
| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
1137
|
+
| **`@zenera/cli`** | `zen` - agent projects on the command line |
|
|
1138
|
+
| [`@zenera/neo`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/neo/README.md) | the runtime - agents, models, tools, skills, memory |
|
|
1139
|
+
| [`@zenera/faker`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/faker/README.md) | `zen faker` - a mock API from an openapi/swagger document |
|
|
1140
|
+
| [`@zenera/rag`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/rag/README.md) | `zen rag` - indexing and search for documents and API schemas |
|
|
1141
|
+
| [`@zenera/view`](https://github.com/andreyryabov/ZeneraNeo/blob/main/packages/view/README.md) | `zen view` - a live local viewer for a folder |
|
|
1142
|
+
|
|
1128
1143
|
## License
|
|
1129
1144
|
|
|
1130
1145
|
Early days and moving fast - issues, questions and pull requests are welcome.
|
package/dist/catalog.d.ts
CHANGED
|
@@ -142,8 +142,13 @@ export declare function matches(row: CatalogEntry, query: string, filters?: Filt
|
|
|
142
142
|
export declare const catalogProviders: () => readonly Provider[];
|
|
143
143
|
/** Resold on Vertex rather than made by Google. */
|
|
144
144
|
export declare const isPartner: (e: CatalogEntry) => boolean;
|
|
145
|
-
/** Provider, then Google's own models, then each partner's together. */
|
|
145
|
+
/** Provider, then Google's own models, then each partner's together; best first within each. */
|
|
146
146
|
export declare function byGroup(a: CatalogEntry, b: CatalogEntry): number;
|
|
147
|
-
/** The heading a row is listed under
|
|
148
|
-
export declare function groupOf(e: CatalogEntry): string
|
|
147
|
+
/** The heading a row is listed under: its provider, split by maker where the provider resells. */
|
|
148
|
+
export declare function groupOf(e: CatalogEntry): string;
|
|
149
|
+
/**
|
|
150
|
+
* At most `limit` rows, shared evenly between providers and kept in order, so a
|
|
151
|
+
* long first listing does not push the others off a truncated screen.
|
|
152
|
+
*/
|
|
153
|
+
export declare function sampleByProvider(rows: readonly CatalogEntry[], limit: number): CatalogEntry[];
|
|
149
154
|
//# sourceMappingURL=catalog.d.ts.map
|
package/dist/catalog.js
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Note what is *not* here: any judgement about which model is good. The lists
|
|
8
8
|
// below are ordered by how likely a call is to succeed and how little it costs
|
|
9
|
-
// to find out, not by quality — a recovery path, not a recommender. The
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// to find out, not by quality — a recovery path, not a recommender. The one
|
|
10
|
+
// judgement the CLI does make lives in `rank.ts`, read off the ids a listing
|
|
11
|
+
// returns, so that no table here has to be argued about every quarter.
|
|
12
12
|
//
|
|
13
13
|
// Nor is there a single hard-coded table of every model. Vendors ship models
|
|
14
14
|
// faster than a release cycle, so the shipped table is a *fallback* and the
|
|
@@ -20,6 +20,7 @@ import { GATEWAY_KEY_HEADER, GATEWAY_NAME, GATEWAY_UPSTREAMS, gatewayOf, ModelRe
|
|
|
20
20
|
import { Cache, cacheKey } from "./cache.js";
|
|
21
21
|
import { PROVIDERS } from "./keys.js";
|
|
22
22
|
import { classify } from "./liveness.js";
|
|
23
|
+
import { compareModels } from "./rank.js";
|
|
23
24
|
/** A day. Model lists change on the scale of weeks; a stale row costs a retry. */
|
|
24
25
|
export const CATALOG_TTL_MS = 24 * 60 * 60 * 1000;
|
|
25
26
|
export const CATALOG_KIND = 'catalog';
|
|
@@ -419,7 +420,13 @@ async function fetchGatewayListing(at) {
|
|
|
419
420
|
return by;
|
|
420
421
|
}
|
|
421
422
|
function publisherOf(id) {
|
|
422
|
-
|
|
423
|
+
if (/^claude-/.test(id)) {
|
|
424
|
+
return 'anthropic';
|
|
425
|
+
}
|
|
426
|
+
if (/^(mistral|codestral)-/.test(id)) {
|
|
427
|
+
return 'mistral';
|
|
428
|
+
}
|
|
429
|
+
return id.includes('/') ? id.split('/')[0] : 'google';
|
|
423
430
|
}
|
|
424
431
|
/** What the gateway serves under one provider's name, enriched like a vendor listing. */
|
|
425
432
|
export async function fetchGatewayCatalog(provider, at) {
|
|
@@ -669,20 +676,53 @@ export const catalogProviders = () => PROVIDERS;
|
|
|
669
676
|
// ---------------------------------------------------------------------------
|
|
670
677
|
/** Resold on Vertex rather than made by Google. */
|
|
671
678
|
export const isPartner = (e) => e.publisher !== undefined && e.publisher !== 'google';
|
|
672
|
-
/**
|
|
679
|
+
/** The order listings are shown in: the vendors in rank order, then the resellers. */
|
|
680
|
+
const GROUP_ORDER = ['anthropic', 'openai', 'google', 'vertex', 'openrouter'];
|
|
681
|
+
/** Provider, then Google's own models, then each partner's together; best first within each. */
|
|
673
682
|
export function byGroup(a, b) {
|
|
674
|
-
return (a.provider.
|
|
683
|
+
return (GROUP_ORDER.indexOf(a.provider) - GROUP_ORDER.indexOf(b.provider) ||
|
|
675
684
|
Number(isPartner(a)) - Number(isPartner(b)) ||
|
|
676
685
|
(a.publisher ?? '').localeCompare(b.publisher ?? '') ||
|
|
677
|
-
a
|
|
686
|
+
compareModels(a, b));
|
|
678
687
|
}
|
|
679
|
-
/** The heading a row is listed under
|
|
688
|
+
/** The heading a row is listed under: its provider, split by maker where the provider resells. */
|
|
680
689
|
export function groupOf(e) {
|
|
681
690
|
if (!e.publisher) {
|
|
682
|
-
return
|
|
691
|
+
return e.provider;
|
|
683
692
|
}
|
|
684
693
|
return isPartner(e)
|
|
685
694
|
? `${e.provider} · partner · ${e.publisher}`
|
|
686
695
|
: `${e.provider} · Google (Gemini, Gemma, Imagen, Veo)`;
|
|
687
696
|
}
|
|
697
|
+
/**
|
|
698
|
+
* At most `limit` rows, shared evenly between providers and kept in order, so a
|
|
699
|
+
* long first listing does not push the others off a truncated screen.
|
|
700
|
+
*/
|
|
701
|
+
export function sampleByProvider(rows, limit) {
|
|
702
|
+
if (rows.length <= limit) {
|
|
703
|
+
return [...rows];
|
|
704
|
+
}
|
|
705
|
+
const counts = new Map();
|
|
706
|
+
for (const row of rows) {
|
|
707
|
+
const c = counts.get(row.provider) ?? { total: 0, take: 0 };
|
|
708
|
+
c.total++;
|
|
709
|
+
counts.set(row.provider, c);
|
|
710
|
+
}
|
|
711
|
+
for (let left = limit, gave = true; left > 0 && gave;) {
|
|
712
|
+
gave = false;
|
|
713
|
+
for (const c of counts.values()) {
|
|
714
|
+
if (left > 0 && c.take < c.total) {
|
|
715
|
+
c.take++;
|
|
716
|
+
left--;
|
|
717
|
+
gave = true;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
const seen = new Map();
|
|
722
|
+
return rows.filter((row) => {
|
|
723
|
+
const n = (seen.get(row.provider) ?? 0) + 1;
|
|
724
|
+
seen.set(row.provider, n);
|
|
725
|
+
return n <= counts.get(row.provider).take;
|
|
726
|
+
});
|
|
727
|
+
}
|
|
688
728
|
//# sourceMappingURL=catalog.js.map
|
package/dist/commands/meta.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readProjectConfig } from '@zenera/neo';
|
|
1
|
+
import { gatewayOf, readProjectConfig } from '@zenera/neo';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { existsSync, readFileSync } from 'node:fs';
|
|
@@ -11,7 +11,7 @@ import { copilotEventLog } from "../eventlog.js";
|
|
|
11
11
|
import { ensureHome } from "../home.js";
|
|
12
12
|
import { assertOwner, isProvider, KeyStore, PROVIDERS } from "../keys.js";
|
|
13
13
|
import { probeChat, probeModel } from "../liveness.js";
|
|
14
|
-
import { answerBox, chooseModel, DATASET_DETAILS, DATASET_SUMMARY, DATASET_USAGE, datasetProjectFlag, DEFAULT_RESUMES,
|
|
14
|
+
import { answerBox, chooseModel, DATASET_DETAILS, DATASET_SUMMARY, DATASET_USAGE, datasetProjectFlag, DEFAULT_RESUMES, editorFiles, fallbackRef, FINETUNE_DETAILS, FINETUNE_SUMMARY, FINETUNE_USAGE, finetuneProjectFlag, lastSession, launch, listPrompts, loadPrompt, locate, masked, metaCompatible, misspelledProvider, MODEL_ENV, openLog, ORDER, PROMPT_DIR, providersWarning, readMeta, recordSession, RESUME_PROMPT, resumeDelayMs, runDataset, runFinetune, sameModel, sessionModel, sessionTotals, SOURCE_LABELS, startVertexRelay, tailSpans, Tally, terminalSink, transient, wire, wireGateway, writeMeta, } from "../meta/index.js";
|
|
15
15
|
import { splitRef } from "../modelref.js";
|
|
16
16
|
import { duration } from "../narrate.js";
|
|
17
17
|
import * as Projects from "../projects.js";
|
|
@@ -169,10 +169,13 @@ export const meta = {
|
|
|
169
169
|
'The model is looked for in this order: --model, ZENERA_META_MODEL in the',
|
|
170
170
|
"shell, ZENERA_META_MODEL in the project's .env, `zen meta model`, then",
|
|
171
171
|
'agents.yaml. With none of them set it uses the best model it knows of for',
|
|
172
|
-
'a provider you hold a key for, and says which on stderr.',
|
|
172
|
+
'a provider you hold a key for, and says which on stderr. Through a gateway',
|
|
173
|
+
'it asks what the gateway serves and takes the best of that, as ranked by',
|
|
174
|
+
'`zen models ls --chat --best`.',
|
|
173
175
|
'',
|
|
174
176
|
'Setting one asks the provider a one-word question first, so a model that',
|
|
175
|
-
|
|
177
|
+
"will not answer is never stored. A model whose API rejects copilot's tools",
|
|
178
|
+
'(gpt-4o and older) is refused before that. --force skips both.',
|
|
176
179
|
'',
|
|
177
180
|
'`zen meta model --pick` browses every model your keys reach: / filters,',
|
|
178
181
|
't asks one a question, p takes it.',
|
|
@@ -554,15 +557,17 @@ async function go(ctx, values, project, prompt, promptName) {
|
|
|
554
557
|
ensureHome();
|
|
555
558
|
const store = await KeyStore.open();
|
|
556
559
|
store.materialize();
|
|
560
|
+
// After materialize: a keyring in bifrost mode is what sets the url.
|
|
561
|
+
const gateway = gatewayOf();
|
|
557
562
|
const only = values.provider ? asProvider(values.provider) : undefined;
|
|
563
|
+
// The fallback can cost a gateway listing, so it is only looked up when nothing else answers.
|
|
558
564
|
const chosen = chooseModel({
|
|
559
565
|
flag: values.model,
|
|
560
566
|
shell,
|
|
561
567
|
env: process.env[MODEL_ENV],
|
|
562
568
|
stored: (await readMeta()).model,
|
|
563
569
|
project: projectModel(project.dir),
|
|
564
|
-
|
|
565
|
-
});
|
|
570
|
+
}) ?? chooseModel({ fallback: await fallbackRef(store, only, gateway !== undefined) });
|
|
566
571
|
if (!chosen) {
|
|
567
572
|
if (only) {
|
|
568
573
|
throw credentialError(`no ${only} key`, `add one: zen key add ${only}`);
|
|
@@ -577,11 +582,11 @@ async function go(ctx, values, project, prompt, promptName) {
|
|
|
577
582
|
if (meant && !only) {
|
|
578
583
|
warn(`${chosen.ref} names no provider — did you mean ${meant}/${id}?`);
|
|
579
584
|
}
|
|
580
|
-
const entry = store.active(owner);
|
|
581
|
-
if (!entry) {
|
|
585
|
+
const entry = gateway ? undefined : store.active(owner);
|
|
586
|
+
if (!gateway && !entry) {
|
|
582
587
|
throw credentialError(`no ${owner} key`, `add one: zen key add ${owner}`);
|
|
583
588
|
}
|
|
584
|
-
const wiring = wire(store, entry, id);
|
|
589
|
+
const wiring = gateway ? wireGateway(gateway, owner, id) : wire(store, entry, id);
|
|
585
590
|
const binary = locate();
|
|
586
591
|
// Copilot cannot carry a session to another model: the resume fails on the first call.
|
|
587
592
|
const ran = values.resume ? sessionModel(values.resume) : undefined;
|
|
@@ -621,7 +626,10 @@ async function go(ctx, values, project, prompt, promptName) {
|
|
|
621
626
|
warn(competing);
|
|
622
627
|
}
|
|
623
628
|
if (chosen.from === 'default') {
|
|
624
|
-
|
|
629
|
+
const what = gateway
|
|
630
|
+
? 'the best model the gateway serves'
|
|
631
|
+
: `the best ${owner} model zen knows of`;
|
|
632
|
+
note(dim(`no model set — using ${chosen.ref}, ${what}`));
|
|
625
633
|
note(dim('set another: zen meta model <provider>/<id>'));
|
|
626
634
|
}
|
|
627
635
|
note(cyan(`${owner} · ${wiring.model} · ${project.name}`));
|
|
@@ -920,13 +928,15 @@ async function model(ctx, values, args) {
|
|
|
920
928
|
if (ref) {
|
|
921
929
|
return set(ctx, values, project, ref);
|
|
922
930
|
}
|
|
931
|
+
const store = await KeyStore.open();
|
|
932
|
+
store.materialize();
|
|
923
933
|
const sources = {
|
|
924
934
|
flag: undefined,
|
|
925
935
|
shell,
|
|
926
936
|
env: process.env[MODEL_ENV],
|
|
927
937
|
stored: (await readMeta()).model,
|
|
928
938
|
project: project ? projectModel(project.dir) : undefined,
|
|
929
|
-
fallback:
|
|
939
|
+
fallback: await fallbackRef(store, undefined, gatewayOf() !== undefined),
|
|
930
940
|
};
|
|
931
941
|
const chosen = chooseModel(sources);
|
|
932
942
|
const at = {
|
|
@@ -998,11 +1008,14 @@ async function vet(ctx, ref) {
|
|
|
998
1008
|
}
|
|
999
1009
|
const { provider, id } = splitRef(ref);
|
|
1000
1010
|
const owner = provider ?? 'openai';
|
|
1011
|
+
if (!metaCompatible(owner, id)) {
|
|
1012
|
+
throw usageError(`${owner}/${id} cannot run the meta agent: its API rejects copilot's tools`, 'pick a reasoning model, e.g. from: zen models ls --chat --best');
|
|
1013
|
+
}
|
|
1001
1014
|
const store = await KeyStore.open();
|
|
1002
|
-
|
|
1015
|
+
store.materialize();
|
|
1016
|
+
if (!gatewayOf() && !store.active(owner)) {
|
|
1003
1017
|
throw credentialError(`no ${owner} key to try ${id} with`, `add one: zen key add ${owner}`);
|
|
1004
1018
|
}
|
|
1005
|
-
store.materialize();
|
|
1006
1019
|
const bar = ctx.json ? undefined : progress();
|
|
1007
1020
|
bar?.update(dim(`asking ${owner} about ${id} …`));
|
|
1008
1021
|
const probe = await probeModel({
|
|
@@ -1058,13 +1071,12 @@ async function pickModel(ctx) {
|
|
|
1058
1071
|
throw usageError('--pick needs a terminal', 'name the model instead');
|
|
1059
1072
|
}
|
|
1060
1073
|
const store = await KeyStore.open();
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
if (known.length === 0) {
|
|
1074
|
+
store.materialize();
|
|
1075
|
+
const known = store.entries.map((e) => e.provider).filter(isProvider);
|
|
1076
|
+
if (known.length === 0 && !gatewayOf()) {
|
|
1065
1077
|
throw credentialError('no keys to choose from', 'add one: zen key add openai');
|
|
1066
1078
|
}
|
|
1067
|
-
const held = [...new Set(known)];
|
|
1079
|
+
const held = gatewayOf() ? [] : [...new Set(known)];
|
|
1068
1080
|
const { browseModels } = await import("./models.js");
|
|
1069
1081
|
const chosen = await browseModels({
|
|
1070
1082
|
initial: held.length === 1 ? held[0] : undefined,
|
package/dist/commands/models.js
CHANGED
|
@@ -13,11 +13,12 @@
|
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
import { createEmbedder, GATEWAY_NAME, gatewayOf, ModelRegistry } from '@zenera/neo';
|
|
15
15
|
import { parse } from "../args.js";
|
|
16
|
-
import { byGroup, groupOf, isPartner, loadCatalog, loadCatalogs, matches, routerBalance, } from "../catalog.js";
|
|
16
|
+
import { byGroup, groupOf, isPartner, loadCatalog, loadCatalogs, matches, routerBalance, sampleByProvider, } from "../catalog.js";
|
|
17
17
|
import { ensureHome } from "../home.js";
|
|
18
18
|
import { envNames, form, isProvider, KeyStore, PROVIDERS } from "../keys.js";
|
|
19
19
|
import { probeChat, probeModel } from "../liveness.js";
|
|
20
|
-
import {
|
|
20
|
+
import { compareModels } from "../rank.js";
|
|
21
|
+
import { ago, bold, CliError, credentialError, cut, cyan, dim, EXIT, green, isInteractive, json, note, plain, progress, red, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
21
22
|
const USAGE = 'zen models <providers|ls|search|show|test|browse> [ref] [options]';
|
|
22
23
|
/**
|
|
23
24
|
* The keyring is materialised here, and only here.
|
|
@@ -142,18 +143,54 @@ function row(entry) {
|
|
|
142
143
|
dim(entry.name ?? ''),
|
|
143
144
|
];
|
|
144
145
|
}
|
|
145
|
-
/**
|
|
146
|
-
function
|
|
147
|
-
const
|
|
146
|
+
/** Drops every column no row has anything in, so a blank one costs no gap. */
|
|
147
|
+
function filled(rows) {
|
|
148
|
+
const keep = (rows[0] ?? []).map((_, c) => rows.some((r) => plain(r[c] ?? '') !== ''));
|
|
149
|
+
return rows.map((r) => r.filter((_, c) => keep[c]));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* One section per provider (and per maker, where a provider resells), each
|
|
153
|
+
* headed by how much of it is on screen and how fresh it is. The maker is in
|
|
154
|
+
* the heading, so it is not repeated as a column. Columns line up across
|
|
155
|
+
* sections, so the eye can run down them past a heading.
|
|
156
|
+
*/
|
|
157
|
+
function sections(shown, all, cats) {
|
|
158
|
+
const totals = new Map();
|
|
159
|
+
for (const e of all) {
|
|
160
|
+
totals.set(groupOf(e), (totals.get(groupOf(e)) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
const counts = new Map();
|
|
163
|
+
for (const e of shown) {
|
|
164
|
+
counts.set(groupOf(e), (counts.get(groupOf(e)) ?? 0) + 1);
|
|
165
|
+
}
|
|
166
|
+
const fresh = new Map(cats.map((c) => [c.provider, c]));
|
|
167
|
+
// An empty cell stays unstyled: style codes around nothing defeat the table's trimEnd.
|
|
168
|
+
const faint = (s) => (s ? dim(s) : '');
|
|
169
|
+
const lines = table(filled(shown.map((e) => [
|
|
170
|
+
cyan(e.ref),
|
|
171
|
+
faint(roleMark(e)),
|
|
172
|
+
faint(tokens(e.contextLength)),
|
|
173
|
+
faint(e.pricing?.free ? 'free' : ''),
|
|
174
|
+
// A gateway names a model by its id, which the ref already says.
|
|
175
|
+
faint(e.name && e.name !== e.id ? e.name : ''),
|
|
176
|
+
])));
|
|
148
177
|
const out = [];
|
|
178
|
+
const headed = new Set();
|
|
179
|
+
// A wrapped row reads as two models; a piped listing keeps every character.
|
|
180
|
+
const fit = (line) => process.stdout.isTTY && process.stdout.columns ? cut(line, process.stdout.columns) : line;
|
|
149
181
|
let last;
|
|
150
|
-
|
|
182
|
+
shown.forEach((e, i) => {
|
|
151
183
|
const group = groupOf(e);
|
|
152
|
-
if (group
|
|
153
|
-
|
|
184
|
+
if (group !== last) {
|
|
185
|
+
const total = totals.get(group);
|
|
186
|
+
const on = counts.get(group);
|
|
187
|
+
const size = on < total ? `${on} of ${total}` : String(total);
|
|
188
|
+
const cat = headed.has(e.provider) ? undefined : fresh.get(e.provider);
|
|
189
|
+
headed.add(e.provider);
|
|
190
|
+
out.push(...(out.length > 0 ? [''] : []), `${bold(group)} ${dim(size)}${cat ? ` ${freshness(cat)}` : ''}`);
|
|
154
191
|
}
|
|
155
192
|
last = group;
|
|
156
|
-
out.push(lines[i]);
|
|
193
|
+
out.push(fit(` ${lines[i]}`));
|
|
157
194
|
});
|
|
158
195
|
return out;
|
|
159
196
|
}
|
|
@@ -162,10 +199,19 @@ function grouped(entries) {
|
|
|
162
199
|
* on stderr, because it is narration about the answer rather than the answer.
|
|
163
200
|
*/
|
|
164
201
|
function explain(cats) {
|
|
202
|
+
// Through a gateway every provider shares one request, so one failure is said once.
|
|
203
|
+
const said = new Set();
|
|
165
204
|
for (const cat of cats) {
|
|
166
205
|
if (cat.problem) {
|
|
167
|
-
|
|
168
|
-
|
|
206
|
+
const who = cat.via ? cat.via.name : cat.provider;
|
|
207
|
+
const detail = cat.problem.detail ?? 'no reason given';
|
|
208
|
+
if (said.has(`${who}\0${detail}`)) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
said.add(`${who}\0${detail}`);
|
|
212
|
+
// A gateway has no built-in list to fall back on.
|
|
213
|
+
const shown = cat.via ? 'nothing listed' : `showing the ${cat.origin} list`;
|
|
214
|
+
note(`${yellow(who)} could not be listed: ${detail} ` + dim(`(${shown})`));
|
|
169
215
|
if (cat.problem.fix) {
|
|
170
216
|
note(dim(` ${cat.problem.fix}`));
|
|
171
217
|
}
|
|
@@ -225,7 +271,8 @@ const ls = async (ctx, args) => {
|
|
|
225
271
|
refresh: { type: 'boolean' },
|
|
226
272
|
limit: { type: 'string' },
|
|
227
273
|
all: { type: 'boolean' },
|
|
228
|
-
|
|
274
|
+
best: { type: 'boolean' },
|
|
275
|
+
}, 'zen models ls [provider] [--chat] [--embeddings] [--best] [--refresh] [--limit N] [--all]');
|
|
229
276
|
const where = await credentials();
|
|
230
277
|
const targets = scope(where, positionals[0]);
|
|
231
278
|
const roles = rolesFrom(values);
|
|
@@ -237,7 +284,7 @@ const ls = async (ctx, args) => {
|
|
|
237
284
|
const all = cats
|
|
238
285
|
.flatMap((c) => c.entries)
|
|
239
286
|
.filter((e) => matches(e, '', { roles }))
|
|
240
|
-
.sort(byGroup);
|
|
287
|
+
.sort(values.best ? compareModels : byGroup);
|
|
241
288
|
if (ctx.json) {
|
|
242
289
|
json({
|
|
243
290
|
models: all,
|
|
@@ -253,14 +300,20 @@ const ls = async (ctx, args) => {
|
|
|
253
300
|
announce(ctx, where);
|
|
254
301
|
explain(cats);
|
|
255
302
|
if (all.length === 0) {
|
|
256
|
-
|
|
303
|
+
if (!cats.every((c) => c.problem)) {
|
|
304
|
+
note(dim('nothing matched — try: zen models ls --refresh'));
|
|
305
|
+
}
|
|
257
306
|
return;
|
|
258
307
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
308
|
+
// A ranking keeps its top; a grouped listing shows some of every provider.
|
|
309
|
+
const shown = values.best ? all.slice(0, limit) : sampleByProvider(all, limit);
|
|
310
|
+
writeAll(values.best ? table(shown.map(row)) : sections(shown, all, cats));
|
|
311
|
+
if (all.length > shown.length) {
|
|
312
|
+
const one = targets.length === 1 ? targets[0] : '<provider>';
|
|
313
|
+
note('');
|
|
314
|
+
note(dim(`showing ${shown.length} of ${all.length} — zen models ls ${one} --all, or zen models search <words>`));
|
|
262
315
|
}
|
|
263
|
-
if (!where.gateway &&
|
|
316
|
+
if (!where.gateway && shown.some(isPartner)) {
|
|
264
317
|
note(dim('partner models are billed through Vertex and must be enabled in Model Garden first: https://console.cloud.google.com/vertex-ai/model-garden'));
|
|
265
318
|
}
|
|
266
319
|
};
|
|
@@ -277,7 +330,8 @@ const search = async (ctx, args) => {
|
|
|
277
330
|
'min-context': { type: 'string' },
|
|
278
331
|
limit: { type: 'string' },
|
|
279
332
|
refresh: { type: 'boolean' },
|
|
280
|
-
|
|
333
|
+
best: { type: 'boolean' },
|
|
334
|
+
}, 'zen models search <query> [--provider p] [--embeddings] [--tools] [--vision] [--free] [--min-context N] [--best] [--limit N]');
|
|
281
335
|
const query = positionals.join(' ').trim();
|
|
282
336
|
const where = await credentials();
|
|
283
337
|
const targets = scope(where, values.provider);
|
|
@@ -298,8 +352,10 @@ const search = async (ctx, args) => {
|
|
|
298
352
|
.filter((e) => matches(e, query, filters))
|
|
299
353
|
// Cheapest first when price is known: search is usually the step before
|
|
300
354
|
// picking one, and the free ones are what most people are looking for.
|
|
301
|
-
.sort(
|
|
302
|
-
|
|
355
|
+
.sort(values.best
|
|
356
|
+
? compareModels
|
|
357
|
+
: (a, b) => Number(b.pricing?.free ?? false) - Number(a.pricing?.free ?? false) ||
|
|
358
|
+
byGroup(a, b));
|
|
303
359
|
if (ctx.json) {
|
|
304
360
|
json({ query, matched: hits.length, models: hits.slice(0, limit) });
|
|
305
361
|
return;
|
|
@@ -622,6 +678,11 @@ export const models = {
|
|
|
622
678
|
' zen models test <ref> … Ask it one real question.',
|
|
623
679
|
' zen models browse [provider] Walk providers and models with the',
|
|
624
680
|
' arrow keys; t asks one, p prints its ref.',
|
|
681
|
+
'',
|
|
682
|
+
'Listings are grouped by provider, best model first in each. `ls --best`',
|
|
683
|
+
'and `search --best` drop the groups for one list, best first: Anthropic,',
|
|
684
|
+
'OpenAI, Google, then the rest; newer generations and higher tiers ahead;',
|
|
685
|
+
'mini, nano, lite and haiku last. A heuristic read off the ids, not a benchmark.',
|
|
625
686
|
],
|
|
626
687
|
run: async (ctx) => {
|
|
627
688
|
const [name, ...rest] = ctx.args;
|
package/dist/keys.d.ts
CHANGED
|
@@ -202,7 +202,12 @@ export declare class KeyStore {
|
|
|
202
202
|
* since its url alone is what switches the library over.
|
|
203
203
|
*/
|
|
204
204
|
environment(only?: KeyOwner[]): Record<string, string>;
|
|
205
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Copies the keyring's active credentials into `process.env`, the only
|
|
207
|
+
* place the library looks. Must run before anything that reads them —
|
|
208
|
+
* in `bifrost` mode this is what sets `BIFROST_BASE_URL`, so `gatewayOf()`
|
|
209
|
+
* returns undefined until it has. Returns what it set.
|
|
210
|
+
*/
|
|
206
211
|
materialize(only?: KeyOwner[]): Record<string, string>;
|
|
207
212
|
}
|
|
208
213
|
/**
|
package/dist/keys.js
CHANGED
|
@@ -482,7 +482,12 @@ export class KeyStore {
|
|
|
482
482
|
}
|
|
483
483
|
return env;
|
|
484
484
|
}
|
|
485
|
-
/**
|
|
485
|
+
/**
|
|
486
|
+
* Copies the keyring's active credentials into `process.env`, the only
|
|
487
|
+
* place the library looks. Must run before anything that reads them —
|
|
488
|
+
* in `bifrost` mode this is what sets `BIFROST_BASE_URL`, so `gatewayOf()`
|
|
489
|
+
* returns undefined until it has. Returns what it set.
|
|
490
|
+
*/
|
|
486
491
|
materialize(only) {
|
|
487
492
|
const env = this.environment(only);
|
|
488
493
|
Object.assign(process.env, env);
|
package/dist/meta/host.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
export { invokedAs, parse } from '../args.ts';
|
|
2
|
+
export { loadCatalogs } from '../catalog.ts';
|
|
2
3
|
export type { EventLine } from '../eventlog.ts';
|
|
3
4
|
export { paths, readJson, writeJson } from '../home.ts';
|
|
4
|
-
export { envOf, type KeyEntry, type KeyStore, type Provider } from '../keys.ts';
|
|
5
|
+
export { envOf, PROVIDERS, type KeyEntry, type KeyStore, type Provider } from '../keys.ts';
|
|
5
6
|
export { PROVIDER_NAMES, splitRef } from '../modelref.ts';
|
|
6
7
|
export { duration } from '../narrate.ts';
|
|
8
|
+
export { rankModels } from '../rank.ts';
|
|
7
9
|
export { copyTree, SHARED_RULES, TEMPLATES } from '../scaffold.ts';
|
|
8
10
|
export { bold, CliError, confirm, credentialError, cut, cyan, dim, EXIT, green, invalidError, json, note, pad, plain, red, styled, table, usageError, write, writeAll, yellow, } from '../term.ts';
|
|
9
11
|
export { safe } from '../trace.ts';
|
package/dist/meta/host.js
CHANGED
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
// a dependency. `test/meta-boundary.test.ts` holds the line.
|
|
7
7
|
// ---------------------------------------------------------------------------
|
|
8
8
|
export { invokedAs, parse } from "../args.js";
|
|
9
|
+
export { loadCatalogs } from "../catalog.js";
|
|
9
10
|
export { paths, readJson, writeJson } from "../home.js";
|
|
10
|
-
export { envOf } from "../keys.js";
|
|
11
|
+
export { envOf, PROVIDERS } from "../keys.js";
|
|
11
12
|
export { PROVIDER_NAMES, splitRef } from "../modelref.js";
|
|
12
13
|
export { duration } from "../narrate.js";
|
|
14
|
+
export { rankModels } from "../rank.js";
|
|
13
15
|
export { copyTree, SHARED_RULES, TEMPLATES } from "../scaffold.js";
|
|
14
16
|
export { bold, CliError, confirm, credentialError, cut, cyan, dim, EXIT, green, invalidError, json, note, pad, plain, red, styled, table, usageError, write, writeAll, yellow, } from "../term.js";
|
|
15
17
|
export { safe } from "../trace.js";
|
package/dist/meta/model.d.ts
CHANGED
|
@@ -21,12 +21,29 @@ export interface ModelSources {
|
|
|
21
21
|
}
|
|
22
22
|
/** Where the answer is looked for, best first. */
|
|
23
23
|
export declare const ORDER: readonly ModelSource[];
|
|
24
|
+
/**
|
|
25
|
+
* Decides which model `zen meta` runs copilot on, when it can be named in six
|
|
26
|
+
* places at once. The source comes back too, so a surprising pick can be traced
|
|
27
|
+
* ("from agents.yaml model:") instead of guessed at.
|
|
28
|
+
*/
|
|
24
29
|
export declare function chooseModel(sources: ModelSources): ModelChoice | undefined;
|
|
25
30
|
export declare const SOURCE_LABELS: Record<ModelSource, string>;
|
|
26
31
|
/** First of each row is the default; the rest are the next best. */
|
|
27
32
|
export declare const RECOMMENDED: Partial<Record<Provider, readonly string[]>>;
|
|
28
|
-
/**
|
|
29
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The model `zen meta` falls back to when nothing names one, so a machine with
|
|
35
|
+
* just a key (or just a gateway, which reaches every provider) still runs.
|
|
36
|
+
* `only` is `--provider`: the recommendation for that provider, if it is held.
|
|
37
|
+
*/
|
|
38
|
+
export declare function defaultRef(store: KeyStore, only?: Provider, gateway?: boolean): string | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* The best chat model a gateway actually serves, by `rank.ts`: a vendor
|
|
41
|
+
* recommendation it does not carry would fail on the first call. The store must
|
|
42
|
+
* already be materialized, since that is what puts the gateway in the environment.
|
|
43
|
+
*/
|
|
44
|
+
export declare function gatewayDefaultRef(only?: Provider): Promise<string | undefined>;
|
|
45
|
+
/** The last resort: through a gateway, what it serves; otherwise what a held key recommends. */
|
|
46
|
+
export declare function fallbackRef(store: KeyStore, only?: Provider, gateway?: boolean): Promise<string | undefined>;
|
|
30
47
|
export interface MetaFile {
|
|
31
48
|
version: 1;
|
|
32
49
|
model?: string;
|
package/dist/meta/model.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { paths, PROVIDER_NAMES, readJson, writeJson, } from "./host.js";
|
|
1
|
+
import { loadCatalogs, paths, PROVIDER_NAMES, PROVIDERS, rankModels, readJson, writeJson, } from "./host.js";
|
|
2
|
+
import { metaCompatible } from "./wire.js";
|
|
2
3
|
// ---------------------------------------------------------------------------
|
|
3
4
|
// The model
|
|
4
5
|
// ---------------------------------------------------------------------------
|
|
@@ -13,6 +14,11 @@ export const ORDER = [
|
|
|
13
14
|
'project',
|
|
14
15
|
'default',
|
|
15
16
|
];
|
|
17
|
+
/**
|
|
18
|
+
* Decides which model `zen meta` runs copilot on, when it can be named in six
|
|
19
|
+
* places at once. The source comes back too, so a surprising pick can be traced
|
|
20
|
+
* ("from agents.yaml model:") instead of guessed at.
|
|
21
|
+
*/
|
|
16
22
|
export function chooseModel(sources) {
|
|
17
23
|
const at = {
|
|
18
24
|
flag: sources.flag,
|
|
@@ -57,16 +63,37 @@ export const RECOMMENDED = {
|
|
|
57
63
|
};
|
|
58
64
|
/** Which provider is tried first when several are held. */
|
|
59
65
|
const PREFERENCE = ['anthropic', 'openai', 'vertex', 'google', 'openrouter'];
|
|
60
|
-
/**
|
|
61
|
-
|
|
66
|
+
/**
|
|
67
|
+
* The model `zen meta` falls back to when nothing names one, so a machine with
|
|
68
|
+
* just a key (or just a gateway, which reaches every provider) still runs.
|
|
69
|
+
* `only` is `--provider`: the recommendation for that provider, if it is held.
|
|
70
|
+
*/
|
|
71
|
+
export function defaultRef(store, only, gateway = false) {
|
|
62
72
|
for (const provider of only ? [only] : PREFERENCE) {
|
|
63
73
|
const id = RECOMMENDED[provider]?.[0];
|
|
64
|
-
if (id && store.active(provider)) {
|
|
74
|
+
if (id && (gateway || store.active(provider))) {
|
|
65
75
|
return `${provider}/${id}`;
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
return undefined;
|
|
69
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The best chat model a gateway actually serves, by `rank.ts`: a vendor
|
|
82
|
+
* recommendation it does not carry would fail on the first call. The store must
|
|
83
|
+
* already be materialized, since that is what puts the gateway in the environment.
|
|
84
|
+
*/
|
|
85
|
+
export async function gatewayDefaultRef(only) {
|
|
86
|
+
const cats = await loadCatalogs(only ? [only] : PROVIDERS);
|
|
87
|
+
const usable = cats
|
|
88
|
+
.flatMap((c) => c.entries)
|
|
89
|
+
.filter((e) => e.roles.includes('chat') && metaCompatible(e.provider, e.id));
|
|
90
|
+
const best = rankModels(usable)[0];
|
|
91
|
+
return best ? `${best.provider}/${best.id}` : undefined;
|
|
92
|
+
}
|
|
93
|
+
/** The last resort: through a gateway, what it serves; otherwise what a held key recommends. */
|
|
94
|
+
export async function fallbackRef(store, only, gateway = false) {
|
|
95
|
+
return ((gateway ? await gatewayDefaultRef(only) : undefined) ?? defaultRef(store, only, gateway));
|
|
96
|
+
}
|
|
70
97
|
const EMPTY = { version: 1 };
|
|
71
98
|
export const readMeta = () => readJson(paths.meta(), EMPTY);
|
|
72
99
|
export function writeMeta(file) {
|
package/dist/meta/wire.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type Gateway } from '@zenera/neo';
|
|
1
2
|
import { type KeyEntry, type KeyStore, type Provider } from './host.ts';
|
|
2
3
|
/**
|
|
3
4
|
* Copilot offers its tools as OpenAI *custom* tools, which the completions API
|
|
@@ -7,6 +8,8 @@ import { type KeyEntry, type KeyStore, type Provider } from './host.ts';
|
|
|
7
8
|
* listed rather than newer ones, so each new generation works without a release.
|
|
8
9
|
*/
|
|
9
10
|
export declare function wireApi(provider: Provider, id: string): string | undefined;
|
|
11
|
+
/** Whether copilot's tools survive the model: OpenAI's completions-only models refuse them. */
|
|
12
|
+
export declare function metaCompatible(provider: Provider, id: string): boolean;
|
|
10
13
|
export interface Wiring {
|
|
11
14
|
provider: Provider;
|
|
12
15
|
/** what the model is called on the wire */
|
|
@@ -35,6 +38,12 @@ export declare const RELAY_URL = "http://127.0.0.1:<relay>";
|
|
|
35
38
|
* through a GitHub account we are deliberately not using.
|
|
36
39
|
*/
|
|
37
40
|
export declare function wire(store: KeyStore, entry: KeyEntry, id: string): Wiring;
|
|
41
|
+
/**
|
|
42
|
+
* Through a gateway: one virtual key, and Bifrost's OpenAI or Anthropic drop-in,
|
|
43
|
+
* which routes on an `<upstream>/<id>` wire model. Claude speaks Anthropic
|
|
44
|
+
* wherever it is hosted; everything else goes through the OpenAI one.
|
|
45
|
+
*/
|
|
46
|
+
export declare function wireGateway(at: Gateway, provider: Provider, id: string): Wiring;
|
|
38
47
|
/** Values a `--dry-run` may print. A key is four characters and an apology. */
|
|
39
48
|
export declare function masked(env: Record<string, string>, secret: readonly string[]): string[];
|
|
40
49
|
/** Non-fatal, said once, because a competing file is a silent override. */
|
package/dist/meta/wire.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { GATEWAY_UPSTREAMS } from '@zenera/neo';
|
|
1
2
|
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { credentialError, envOf, invokedAs, yellow, } from "./host.js";
|
|
@@ -26,6 +27,10 @@ export function wireApi(provider, id) {
|
|
|
26
27
|
? 'responses'
|
|
27
28
|
: undefined;
|
|
28
29
|
}
|
|
30
|
+
/** Whether copilot's tools survive the model: OpenAI's completions-only models refuse them. */
|
|
31
|
+
export function metaCompatible(provider, id) {
|
|
32
|
+
return provider !== 'openai' || wireApi(provider, id) !== undefined;
|
|
33
|
+
}
|
|
29
34
|
/** Where the relay's url goes once it is listening. */
|
|
30
35
|
export const RELAY_URL = 'http://127.0.0.1:<relay>';
|
|
31
36
|
const vertexClaude = (id) => /^(anthropic\/)?claude-/.test(id);
|
|
@@ -112,6 +117,31 @@ export function wire(store, entry, id) {
|
|
|
112
117
|
}
|
|
113
118
|
return { provider, model: env.COPILOT_PROVIDER_WIRE_MODEL ?? id, env, secret, warnings };
|
|
114
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Through a gateway: one virtual key, and Bifrost's OpenAI or Anthropic drop-in,
|
|
122
|
+
* which routes on an `<upstream>/<id>` wire model. Claude speaks Anthropic
|
|
123
|
+
* wherever it is hosted; everything else goes through the OpenAI one.
|
|
124
|
+
*/
|
|
125
|
+
export function wireGateway(at, provider, id) {
|
|
126
|
+
const claude = provider === 'anthropic' || (provider === 'vertex' && vertexClaude(id));
|
|
127
|
+
const model = provider === 'vertex' ? id.replace(/^anthropic\//, '') : id;
|
|
128
|
+
const env = {
|
|
129
|
+
COPILOT_PROVIDER_TYPE: claude ? 'anthropic' : 'openai',
|
|
130
|
+
COPILOT_PROVIDER_BASE_URL: `${at.baseURL}${claude ? '/anthropic' : '/openai/v1'}`,
|
|
131
|
+
COPILOT_PROVIDER_MODEL_ID: model.replace(/^google\//, ''),
|
|
132
|
+
COPILOT_PROVIDER_WIRE_MODEL: `${GATEWAY_UPSTREAMS[provider]}/${model}`,
|
|
133
|
+
};
|
|
134
|
+
const api = wireApi(provider, model);
|
|
135
|
+
if (api) {
|
|
136
|
+
env.COPILOT_PROVIDER_WIRE_API = api;
|
|
137
|
+
}
|
|
138
|
+
const secret = [];
|
|
139
|
+
if (at.apiKey) {
|
|
140
|
+
env.COPILOT_PROVIDER_API_KEY = at.apiKey;
|
|
141
|
+
secret.push('COPILOT_PROVIDER_API_KEY');
|
|
142
|
+
}
|
|
143
|
+
return { provider, model: env.COPILOT_PROVIDER_WIRE_MODEL, env, secret, warnings: [] };
|
|
144
|
+
}
|
|
115
145
|
/** Values a `--dry-run` may print. A key is four characters and an apology. */
|
|
116
146
|
export function masked(env, secret) {
|
|
117
147
|
return Object.entries(env)
|
package/dist/rank.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type Maker = 'anthropic' | 'openai' | 'google' | 'other';
|
|
2
|
+
export type Tier = 'top' | 'mid' | 'small';
|
|
3
|
+
export interface ModelTraits {
|
|
4
|
+
maker: Maker;
|
|
5
|
+
/** `[5, 6]` for `gpt-5.6-sol`; empty when the id carries none */
|
|
6
|
+
version: number[];
|
|
7
|
+
tier: Tier;
|
|
8
|
+
/** built for one job: codex, search, audio, image… */
|
|
9
|
+
special: boolean;
|
|
10
|
+
preview: boolean;
|
|
11
|
+
/** a dated snapshot, as `YYYYMMDD` or the vendor's shorter `MMDD` */
|
|
12
|
+
date?: string;
|
|
13
|
+
}
|
|
14
|
+
/** What a listing row needs to be ranked: the provider routing it and its id. */
|
|
15
|
+
export interface Ranked {
|
|
16
|
+
provider: string;
|
|
17
|
+
id: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function parseModel(id: string): ModelTraits;
|
|
20
|
+
/** Best first. Usable as a sort comparator on any listing row. */
|
|
21
|
+
export declare function compareModels(a: Ranked, b: Ranked): number;
|
|
22
|
+
/** A sorted copy, best first. */
|
|
23
|
+
export declare function rankModels<T extends Ranked>(rows: readonly T[]): T[];
|
|
24
|
+
//# sourceMappingURL=rank.d.ts.map
|
package/dist/rank.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Ranking models
|
|
3
|
+
//
|
|
4
|
+
// A heuristic read off the id alone, not a benchmark. Vendors name models by
|
|
5
|
+
// generation and tier, so both can be parsed and compared without a table
|
|
6
|
+
// that goes stale with every release: `gpt-6-sol` outranks `gpt-5.6-sol` the
|
|
7
|
+
// day it is listed.
|
|
8
|
+
//
|
|
9
|
+
// Best first: general models before specialised ones and small tiers last;
|
|
10
|
+
// then Anthropic, OpenAI, Google, everyone else; then the newer generation;
|
|
11
|
+
// then the higher tier; then the rest of the version.
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
const MAKERS = ['anthropic', 'openai', 'google', 'other'];
|
|
14
|
+
const TIERS = ['top', 'mid', 'small'];
|
|
15
|
+
const SMALL = new Set(['haiku', 'luna', 'mini', 'nano', 'lite', 'small', 'tiny']);
|
|
16
|
+
const TOP = new Set(['opus', 'sol', 'pro', 'ultra', 'argon', 'max']);
|
|
17
|
+
const PREVIEW = new Set(['preview', 'exp', 'experimental', 'beta', 'alpha']);
|
|
18
|
+
// Not `instruct`: open-weight chat models carry it (`qwen3-…-instruct`) as their general release.
|
|
19
|
+
const SPECIAL = /codex|search|audio|realtime|image|tts|transcribe|computer-use|embedding|research|(^|-)live(-|$)/;
|
|
20
|
+
function makerOf(name) {
|
|
21
|
+
if (name.startsWith('claude')) {
|
|
22
|
+
return 'anthropic';
|
|
23
|
+
}
|
|
24
|
+
// Open weights under OpenAI's name, served by others.
|
|
25
|
+
if (name.startsWith('gpt-oss')) {
|
|
26
|
+
return 'other';
|
|
27
|
+
}
|
|
28
|
+
if (/^(gpt|chatgpt|o\d)/.test(name)) {
|
|
29
|
+
return 'openai';
|
|
30
|
+
}
|
|
31
|
+
return name.startsWith('gemini') ? 'google' : 'other';
|
|
32
|
+
}
|
|
33
|
+
const parts = (text) => text.split('.').map(Number);
|
|
34
|
+
export function parseModel(id) {
|
|
35
|
+
// Routed ids carry their maker (`anthropic/claude-opus-5`) and OpenRouter a variant (`:free`).
|
|
36
|
+
const routed = (id.toLowerCase().split('/').pop() ?? '').replace(/:.*$/, '');
|
|
37
|
+
// Vertex dates Claude with an at sign: `claude-opus-4-5@20251101`.
|
|
38
|
+
const [name = '', at] = routed.split('@');
|
|
39
|
+
const maker = makerOf(name);
|
|
40
|
+
const words = name.split('-');
|
|
41
|
+
const traits = {
|
|
42
|
+
maker,
|
|
43
|
+
version: [],
|
|
44
|
+
tier: 'mid',
|
|
45
|
+
special: SPECIAL.test(name),
|
|
46
|
+
preview: false,
|
|
47
|
+
...(at && /^\d{8}$/.test(at) ? { date: at } : {}),
|
|
48
|
+
};
|
|
49
|
+
let ended = false;
|
|
50
|
+
for (let i = 0; i < words.length; i++) {
|
|
51
|
+
const word = words[i];
|
|
52
|
+
if (/^\d{8}$/.test(word)) {
|
|
53
|
+
traits.date = word;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (/^20\d\d$/.test(word) &&
|
|
57
|
+
/^\d\d$/.test(words[i + 1] ?? '') &&
|
|
58
|
+
/^\d\d$/.test(words[i + 2] ?? '')) {
|
|
59
|
+
traits.date = word + words[i + 1] + words[i + 2];
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
// `gpt-4-0613`, `gpt-3.5-turbo-0125`
|
|
64
|
+
if (/^\d{4}$/.test(word) && traits.version.length > 0) {
|
|
65
|
+
traits.date = word;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (PREVIEW.has(word)) {
|
|
69
|
+
traits.preview = true;
|
|
70
|
+
}
|
|
71
|
+
if (SMALL.has(word)) {
|
|
72
|
+
traits.tier = 'small';
|
|
73
|
+
}
|
|
74
|
+
else if (TOP.has(word) && traits.tier !== 'small') {
|
|
75
|
+
traits.tier = 'top';
|
|
76
|
+
}
|
|
77
|
+
if (ended) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// OpenAI's o-series came between GPT-4 and GPT-5.
|
|
81
|
+
const series = i === 0 && maker === 'openai' ? /^o(\d+)$/.exec(word) : null;
|
|
82
|
+
if (series) {
|
|
83
|
+
traits.version = [4, Number(series[1])];
|
|
84
|
+
ended = true;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
// `qwen3`, `llama4`: the generation glued to the family name.
|
|
88
|
+
const glued = i === 0 ? /^[a-z]+(\d+(?:\.\d+)*)$/.exec(word) : null;
|
|
89
|
+
if (glued) {
|
|
90
|
+
traits.version = parts(glued[1]);
|
|
91
|
+
ended = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
// `5.6`, `4o`, `v3`, or the `4`, `5` of `opus-4-5`; sizes like `120b` are not versions.
|
|
95
|
+
const number = /^v?(\d{1,2}(?:\.\d+)*)o?$/.exec(word);
|
|
96
|
+
if (number) {
|
|
97
|
+
const next = parts(number[1]);
|
|
98
|
+
traits.version = traits.version.length > 0 ? [...traits.version, next[0]] : next;
|
|
99
|
+
if (word.endsWith('o')) {
|
|
100
|
+
ended = true;
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (traits.version.length > 0) {
|
|
105
|
+
ended = true;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return traits;
|
|
109
|
+
}
|
|
110
|
+
/** The vendor's own endpoint first, then the resellers. */
|
|
111
|
+
function routeRank(provider, maker) {
|
|
112
|
+
if (provider === maker) {
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
return provider === 'vertex' ? 1 : provider === 'openrouter' ? 2 : 3;
|
|
116
|
+
}
|
|
117
|
+
const bucket = (t) => (t.tier === 'small' ? 2 : t.special ? 1 : 0);
|
|
118
|
+
function compareTraits(a, b) {
|
|
119
|
+
const majorA = a.version[0] ?? -1;
|
|
120
|
+
const majorB = b.version[0] ?? -1;
|
|
121
|
+
return (bucket(a) - bucket(b) ||
|
|
122
|
+
MAKERS.indexOf(a.maker) - MAKERS.indexOf(b.maker) ||
|
|
123
|
+
majorB - majorA ||
|
|
124
|
+
TIERS.indexOf(a.tier) - TIERS.indexOf(b.tier) ||
|
|
125
|
+
compareRest(b.version, a.version) ||
|
|
126
|
+
Number(a.preview) - Number(b.preview) ||
|
|
127
|
+
// The alias that follows the newest snapshot, then the newest snapshot.
|
|
128
|
+
Number(a.date !== undefined) - Number(b.date !== undefined) ||
|
|
129
|
+
(b.date ?? '').localeCompare(a.date ?? ''));
|
|
130
|
+
}
|
|
131
|
+
function compareRest(a, b) {
|
|
132
|
+
for (let i = 1; i < Math.max(a.length, b.length); i++) {
|
|
133
|
+
const diff = (a[i] ?? 0) - (b[i] ?? 0);
|
|
134
|
+
if (diff !== 0) {
|
|
135
|
+
return diff;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
/** Best first. Usable as a sort comparator on any listing row. */
|
|
141
|
+
export function compareModels(a, b) {
|
|
142
|
+
const ta = parseModel(a.id);
|
|
143
|
+
const tb = parseModel(b.id);
|
|
144
|
+
return (compareTraits(ta, tb) ||
|
|
145
|
+
routeRank(a.provider, ta.maker) - routeRank(b.provider, tb.maker) ||
|
|
146
|
+
a.id.localeCompare(b.id) ||
|
|
147
|
+
a.provider.localeCompare(b.provider));
|
|
148
|
+
}
|
|
149
|
+
/** A sorted copy, best first. */
|
|
150
|
+
export function rankModels(rows) {
|
|
151
|
+
return [...rows].sort(compareModels);
|
|
152
|
+
}
|
|
153
|
+
//# sourceMappingURL=rank.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zenera/cli",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.33",
|
|
4
4
|
"description": "Command-line front end for @zenera/neo: agentic projects you can run, share and commit.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -54,8 +54,8 @@
|
|
|
54
54
|
"@google/genai": "^2.30.0",
|
|
55
55
|
"@inkjs/ui": "^2.0.0",
|
|
56
56
|
"@openrouter/sdk": "^1.4.25",
|
|
57
|
-
"@zenera/neo": "^1.1.
|
|
58
|
-
"@zenera/view": "^1.1.
|
|
57
|
+
"@zenera/neo": "^1.1.33",
|
|
58
|
+
"@zenera/view": "^1.1.33",
|
|
59
59
|
"google-auth-library": "^10.0.0",
|
|
60
60
|
"ink": "^7.1.1",
|
|
61
61
|
"openai": "^6.0.0",
|
|
@@ -26,6 +26,13 @@ Filters, on `ls` and `search`: `--chat`, `--embeddings`, `--images`, `--audio`,
|
|
|
26
26
|
`--tools`, `--vision`, `--free`, `--min-context <n>`, `--provider <name>`,
|
|
27
27
|
`--limit <n>`, `--all`, `--refresh`.
|
|
28
28
|
|
|
29
|
+
Listings are grouped by provider (Anthropic, OpenAI, Google, Vertex,
|
|
30
|
+
OpenRouter), best model first in each. `--best` drops the groups for one list,
|
|
31
|
+
best first, `--json` included: Anthropic, OpenAI, Google, then the rest; newer
|
|
32
|
+
generation, then higher tier; mini, nano, lite and haiku last. It is a heuristic
|
|
33
|
+
read off the ids, not a benchmark. `zen models ls --chat --best --json` is the
|
|
34
|
+
list to pick a default from.
|
|
35
|
+
|
|
29
36
|
`browse` is for a person at a terminal and refuses without one (exit 2). An
|
|
30
37
|
agent wants `ls --json` or `search` instead.
|
|
31
38
|
|