@turri-dev/cli 0.1.9-next.73bfddf → 0.1.10-next.2abf2db

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.
@@ -6,20 +6,122 @@ import { load as loadYaml } from "js-yaml";
6
6
  import { adoptLegacyConfig, CONFIG_FILE, CONFIG_KEYS, LEGACY_CONFIG_FILE, ROLE_THINKING } from "./config.js";
7
7
  import { withTopLevelValue } from "./config-edit.js";
8
8
  import { brand } from "./brand.js";
9
- /** The geographies Bedrock serves each maker's two models in, as pi knows them - the first the default. */
9
+ import { closestModels, piModelCatalogue } from "./model-check.js";
10
+ /** The geographies turri's own pair for each maker is served in - the first the default. Every other: bedrockMakers. */
10
11
  export const BEDROCK_REGIONS = {
11
12
  anthropic: ["global", "us", "eu", "jp", "au"],
12
13
  openai: ["global", "us"],
13
14
  };
14
- /** An AWS region in each geography, for AWS_REGION: pi calls Bedrock there, us-east-1 when none is set. */
15
- const AWS_REGION_IN = { global: "us-east-1", us: "us-east-1", eu: "eu-central-1", jp: "ap-northeast-1", au: "ap-southeast-2" };
16
- /** Bedrock's models for a maker in a geography: Opus 5.5 and Haiku 5.5, or GPT-6 Astra and Luna. */
15
+ /** The geographies an inference profile's id starts with, in the order they are offered. */
16
+ export const BEDROCK_GEOGRAPHIES = ["global", "us", "eu", "jp", "au", "apac", "in", "ca", "us-gov"];
17
+ /** A Bedrock model whose id names no geography: served in the AWS region it is called in. */
18
+ export const BEDROCK_IN_REGION = "in-region";
19
+ /** An AWS region in each geography, for AWS_REGION: pi calls Bedrock there - us-east-1 when nothing names another. */
20
+ const AWS_REGION_IN = {
21
+ global: "us-east-1",
22
+ us: "us-east-1",
23
+ eu: "eu-central-1",
24
+ jp: "ap-northeast-1",
25
+ au: "ap-southeast-2",
26
+ apac: "ap-southeast-1",
27
+ in: "ap-south-1",
28
+ ca: "ca-central-1",
29
+ "us-gov": "us-gov-west-1",
30
+ };
31
+ /** The AWS region to call a geography's models in - AWS_REGION's value. */
32
+ export function bedrockAwsRegion(geography) {
33
+ return AWS_REGION_IN[geography] ?? "us-east-1";
34
+ }
35
+ /**
36
+ * Whether a geography's models need AWS_REGION set: every one but global, us and in-region, which pi's us-east-1 serves.
37
+ * pi calls Bedrock in AWS_REGION, else - with an AWS_PROFILE - the profile's region, else us-east-1. It pins eu's
38
+ * endpoint itself only with neither AWS_REGION nor AWS_PROFILE set, so eu needs it too.
39
+ */
40
+ export function bedrockNeedsAwsRegion(geography) {
41
+ return !["global", "us", BEDROCK_IN_REGION].includes(geography);
42
+ }
43
+ /** Where a Bedrock model is served and whose it is, from its id or spec: `global.xai.grok-4.7` - global, xai; `meta.llama4` - in-region, meta. */
44
+ export function bedrockPlaceOf(model) {
45
+ const [first = "", second = ""] = model.replace(/^amazon-bedrock\//, "").split(".");
46
+ return BEDROCK_GEOGRAPHIES.includes(first) ? { geography: first, maker: second } : { geography: BEDROCK_IN_REGION, maker: first };
47
+ }
48
+ /** A maker's name, as the messages and questions say it. */
49
+ const MAKER_LABELS = {
50
+ anthropic: "Anthropic",
51
+ openai: "OpenAI",
52
+ google: "Google",
53
+ xai: "xAI",
54
+ meta: "Meta",
55
+ mistral: "Mistral",
56
+ qwen: "Qwen",
57
+ deepseek: "DeepSeek",
58
+ amazon: "Amazon",
59
+ moonshot: "Moonshot",
60
+ moonshotai: "Moonshot AI",
61
+ zai: "Z.ai",
62
+ minimax: "MiniMax",
63
+ nvidia: "NVIDIA",
64
+ writer: "Writer",
65
+ };
66
+ /** A maker's - or a provider's - name, as the messages say it: xAI for xai; its id when turri has no name for it. */
67
+ export function makerLabel(maker) {
68
+ return MAKER_LABELS[maker] ?? maker;
69
+ }
70
+ /** Bedrock's chat models in pi's catalogue. */
71
+ const bedrockChat = (catalogue) => catalogue.models("amazon-bedrock").filter((model) => model.type === "chat");
72
+ /** Every maker pi's catalogue serves on Bedrock - Anthropic and OpenAI first, the rest by name - with their geographies. */
73
+ export function bedrockMakers(catalogue) {
74
+ const regions = new Map();
75
+ for (const model of bedrockChat(catalogue)) {
76
+ const { geography, maker } = bedrockPlaceOf(model.id);
77
+ if (maker === "")
78
+ continue;
79
+ regions.set(maker, (regions.get(maker) ?? new Set()).add(geography));
80
+ }
81
+ const order = [...BEDROCK_GEOGRAPHIES, BEDROCK_IN_REGION];
82
+ const first = (maker) => (maker === "anthropic" ? 0 : maker === "openai" ? 1 : 2);
83
+ return [...regions]
84
+ .map(([maker, served]) => ({ maker, label: makerLabel(maker), regions: order.filter((region) => served.has(region)) }))
85
+ .sort((a, b) => first(a.maker) - first(b.maker) || a.label.localeCompare(b.label));
86
+ }
87
+ /** A maker's chat models in a geography, as pi's catalogue has them on Bedrock. */
88
+ export function bedrockModelsIn(catalogue, choice) {
89
+ return bedrockChat(catalogue).filter((model) => {
90
+ const place = bedrockPlaceOf(model.id);
91
+ return place.maker === choice.maker && place.geography === choice.region;
92
+ });
93
+ }
94
+ /** Whether turri's own pair for the maker is served in the geography. */
95
+ function hasPreset(choice) {
96
+ const maker = choice.maker ?? BEDROCK_DEFAULT.maker;
97
+ return (maker === "anthropic" || maker === "openai") && BEDROCK_REGIONS[maker].includes(choice.region);
98
+ }
99
+ /** turri's own pair on Bedrock for a maker in a geography: Opus 5.5 and Haiku 5.5, or GPT-6 Astra and Luna. */
17
100
  export function bedrockModels(choice) {
18
101
  const at = (id) => `amazon-bedrock/${choice.region}.${id}`;
19
- return choice.maker === "anthropic"
102
+ return (choice.maker ?? "anthropic") === "anthropic"
20
103
  ? { harness: at("anthropic.claude-opus-5-5"), coding: at("anthropic.claude-haiku-5-5"), strong: at("anthropic.claude-opus-5-5") }
21
104
  : { harness: at("openai.gpt-6-astra"), coding: at("openai.gpt-6-luna"), strong: at("openai.gpt-6.1-sol") };
22
105
  }
106
+ /**
107
+ * The pair to offer for a maker in a geography: turri's own where it has one; else, from pi's catalogue, the strongest
108
+ * that reasons to talk to - price standing in for strength - and the cheapest costing at least a fifth of it to code,
109
+ * ties to the newest. Undefined when the catalogue has none of the maker's models there.
110
+ */
111
+ export function bedrockDefaultModels(catalogue, choice) {
112
+ if (hasPreset(choice))
113
+ return bedrockModels(choice);
114
+ const models = bedrockModelsIn(catalogue, { maker: choice.maker ?? BEDROCK_DEFAULT.maker, region: choice.region });
115
+ if (models.length === 0)
116
+ return undefined;
117
+ const price = (model) => model.cost.input + model.cost.output;
118
+ const newest = (a, b) => b.id.localeCompare(a.id, undefined, { numeric: true });
119
+ const reasoning = models.some((model) => model.reasoning) ? models.filter((model) => model.reasoning) : models;
120
+ const talk = [...reasoning].sort((a, b) => price(b) - price(a) || newest(a, b))[0];
121
+ const code = models.filter((model) => price(model) >= price(talk) / 5).sort((a, b) => price(a) - price(b) || newest(a, b))[0];
122
+ const spec = (model) => `amazon-bedrock/${model.id}`;
123
+ return { harness: spec(talk), coding: spec(code), strong: spec(talk) };
124
+ }
23
125
  /** Bedrock's default: Anthropic's models, anywhere. */
24
126
  export const BEDROCK_DEFAULT = { maker: "anthropic", region: "global" };
25
127
  export const VENDORS = {
@@ -44,10 +146,10 @@ export const VENDORS = {
44
146
  strong: "google/gemini-3.1-pro-preview",
45
147
  keys: [{ vendor: "Google", variable: "GEMINI_API_KEY" }],
46
148
  },
47
- // Anthropic's or OpenAI's models on AWS - which, and where, is asked: this is the default, Anthropic's anywhere.
149
+ // Any maker's models on AWS - whose, where and which, is asked: this is the default, Anthropic's anywhere.
48
150
  "amazon-bedrock": {
49
151
  label: "Amazon Bedrock",
50
- ...bedrockModels(BEDROCK_DEFAULT),
152
+ ...bedrockModels({ maker: "anthropic", region: BEDROCK_DEFAULT.region }),
51
153
  keys: [{ vendor: "AWS", variable: "AWS_PROFILE" }],
52
154
  },
53
155
  // A mix - the strongest to talk to, a cheap one to code - and the starting point for any other provider pi supports.
@@ -71,9 +173,14 @@ function role(name, model, comment) {
71
173
  const fields = [...(model !== undefined ? [`model: ${model}`] : []), `thinking: ${ROLE_THINKING[name]}`].join(", ");
72
174
  return `${column(`# ${name}: { ${fields} }`, 72)}# ${comment}`;
73
175
  }
74
- /** A vendor's three models - for Bedrock, the maker and geography chosen. */
176
+ /** A vendor's three models - for Bedrock, turri's own pair for the maker and geography chosen. */
75
177
  function modelsOf(vendor, bedrock) {
76
- return vendor === "amazon-bedrock" ? bedrockModels(bedrock ?? BEDROCK_DEFAULT) : VENDORS[vendor];
178
+ if (vendor !== "amazon-bedrock")
179
+ return VENDORS[vendor];
180
+ const choice = bedrock ?? BEDROCK_DEFAULT;
181
+ if (!hasPreset(choice))
182
+ throw new Error(`turri has no pair of its own for ${choice.maker ?? BEDROCK_DEFAULT.maker}'s models on Bedrock (${choice.region}): name the two models`);
183
+ return bedrockModels(choice);
77
184
  }
78
185
  /** The two model lines: the one you talk to thinking medium, the coding one low - their comments in one column. */
79
186
  function modelLines(harness, coding) {
@@ -126,9 +233,9 @@ export const DEFAULTS = {
126
233
  export function defaultParallel(cores = availableParallelism()) {
127
234
  return Math.min(4, Math.max(1, Math.floor(cores / 2)));
128
235
  }
129
- /** The quick start config.turri.yaml for a vendor's models - for Bedrock, the maker and geography chosen. */
130
- export function quickStartConfig(vendor = "other", bedrock) {
131
- const { harness, coding, strong } = modelsOf(vendor, bedrock);
236
+ /** The quick start config.turri.yaml for a vendor's models - for Bedrock, the maker and geography chosen - or the models given. */
237
+ export function quickStartConfig(vendor = "other", bedrock, models) {
238
+ const { harness, coding, strong } = models ?? modelsOf(vendor, bedrock);
132
239
  return [
133
240
  "# Any model pi supports works (https://pi.dev): a capable one to talk to, a smaller, cheaper one for the coding.",
134
241
  ...modelLines(harness, coding),
@@ -248,19 +355,23 @@ export function vendorOf(answer) {
248
355
  return "amazon-bedrock";
249
356
  return byNumber ?? names.find((name) => name === typed || VENDORS[name].label.toLowerCase() === typed);
250
357
  }
251
- /** Bedrock's maker from what was typed: its number - 1 Anthropic, 2 OpenAI - or its name. */
252
- export function bedrockMakerOf(answer) {
358
+ /** The makers offered, as numbered: pi's catalogue's, or without it turri's two. */
359
+ function makersOffered(catalogue) {
360
+ return catalogue !== undefined
361
+ ? bedrockMakers(catalogue)
362
+ : Object.keys(BEDROCK_REGIONS).map((maker) => ({ maker, label: makerLabel(maker), regions: BEDROCK_REGIONS[maker] }));
363
+ }
364
+ /** Bedrock's maker from what was typed: its number in the list - with no catalogue, 1 Anthropic, 2 OpenAI - or its name. */
365
+ export function bedrockMakerOf(answer, catalogue) {
253
366
  const typed = answer.trim().toLowerCase();
254
- if (typed === "1" || typed === "anthropic")
255
- return "anthropic";
256
- if (typed === "2" || typed === "openai")
257
- return "openai";
258
- return undefined;
367
+ const offered = makersOffered(catalogue);
368
+ const byNumber = /^\d+$/.test(typed) ? offered[Number(typed) - 1] : undefined;
369
+ return (byNumber ?? offered.find((offer) => offer.maker === typed || offer.label.toLowerCase() === typed))?.maker;
259
370
  }
260
- /** A geography the maker's models are served in, from what was typed: its number in the list, or its name. */
261
- export function bedrockRegionOf(maker, answer) {
371
+ /** A geography the maker's models are served in, from what was typed: its number in the list, or its name - pi's catalogue's, or without it turri's pair's. */
372
+ export function bedrockRegionOf(maker, answer, catalogue) {
262
373
  const typed = answer.trim().toLowerCase();
263
- const regions = BEDROCK_REGIONS[maker];
374
+ const regions = makersOffered(catalogue).find((offer) => offer.maker === maker)?.regions ?? [];
264
375
  return (/^\d+$/.test(typed) ? regions[Number(typed) - 1] : undefined) ?? regions.find((region) => region === typed);
265
376
  }
266
377
  /** The shell profile an exported key is kept in, for the shell this person uses. */
@@ -273,8 +384,12 @@ function shellProfile() {
273
384
  return "~/.bashrc";
274
385
  }
275
386
  /** How to sign in to the vendor's models, as pi takes them: a key in the environment, or /login - a few lines. */
276
- function signInText(vendor, bedrock) {
277
- const { keys } = VENDORS[vendor];
387
+ function signInText(vendor, bedrock, models) {
388
+ // Models given on another vendor's terms - other's, a provider of their own: the keys of the providers they name.
389
+ const providers = models !== undefined && vendor === "other" ? [...new Set([models.harness, models.coding].map((model) => model.split("/")[0]))] : [];
390
+ const keys = providers.length > 0
391
+ ? providers.flatMap((provider) => (provider in VENDORS && provider !== "other" && provider !== "amazon-bedrock" ? VENDORS[provider].keys : [{ vendor: makerLabel(provider), variable: "" }]))
392
+ : VENDORS[vendor].keys;
278
393
  const windows = process.platform === "win32";
279
394
  const set = (variable, value = "...") => brand.accent(windows ? `setx ${variable} "${value}"` : `export ${variable}=${value}`);
280
395
  // Bedrock's are several; a vendor's own key is one, or the mix's two.
@@ -283,7 +398,8 @@ function signInText(vendor, bedrock) {
283
398
  ? `setx keeps ${several ? "them" : "it"} for new terminals - open one before you run turri.`
284
399
  : `Add ${several ? "them" : "it"} to ${shellProfile()} to keep ${several ? "them" : "it"}.`;
285
400
  if (vendor === "amazon-bedrock") {
286
- const region = AWS_REGION_IN[(bedrock ?? BEDROCK_DEFAULT).region] ?? "us-east-1";
401
+ const geography = (bedrock ?? BEDROCK_DEFAULT).region;
402
+ const region = bedrockAwsRegion(geography);
287
403
  return [
288
404
  brand.bold("Sign in to AWS"),
289
405
  ` ${set("AWS_PROFILE")} ${brand.muted("your profile (aws configure, or aws sso login)")}`,
@@ -291,25 +407,107 @@ function signInText(vendor, bedrock) {
291
407
  ` ${brand.muted("or, instead of a profile, a Bedrock API key:")} ${set("AWS_BEARER_TOKEN_BEDROCK")}`,
292
408
  "",
293
409
  brand.muted(keep),
410
+ ...(bedrockNeedsAwsRegion(geography)
411
+ ? [brand.muted(`AWS_REGION has to be in ${geography}'s geography, as ${region} is: pi calls Bedrock in us-east-1 unless it - or the profile - names another.`)]
412
+ : []),
294
413
  brand.muted("Your AWS account needs access to the models: Bedrock console, model access."),
295
414
  ];
296
415
  }
297
416
  return [
298
417
  brand.bold(`Sign in to ${keys.map((key) => key.vendor).join(" and ")}`),
299
- ...keys.map((key) => ` ${set(key.variable)}`),
418
+ ...keys.map((key) => (key.variable !== "" ? ` ${set(key.variable)}` : ` ${brand.muted(`the key pi reads for ${key.vendor} - https://pi.dev`)}`)),
300
419
  "",
301
420
  brand.muted(`${keep} Or start turri and type /login.`),
302
- ...(vendor === "other" ? [brand.muted("Another provider: change the two models to any pi supports, and set its key - https://pi.dev.")] : []),
421
+ ...(vendor === "other" && models === undefined ? [brand.muted("Another provider: change the two models to any pi supports, and set its key - https://pi.dev.")] : []),
303
422
  ];
304
423
  }
305
- /** Whose models these are, in the message: "OpenAI's", "a mix of", "OpenAI's on Amazon Bedrock (global)". */
306
- function whose(vendor, bedrock) {
307
- if (vendor === "other")
308
- return "a mix of";
424
+ /** Names joined as a sentence says them: "a", "a and b", "a, b and c". */
425
+ const and = (names) => (names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`);
426
+ /**
427
+ * Whose models these are, in the message - from the models written: "OpenAI's models", "a mix of models", "xAI's models
428
+ * on Amazon Bedrock (global)", "Anthropic's and OpenAI's models" for a pair of two providers given.
429
+ */
430
+ function whose(vendor, models, given) {
431
+ const pair = [models.harness, models.coding];
432
+ if (vendor === "amazon-bedrock") {
433
+ const places = pair.map(bedrockPlaceOf);
434
+ const makers = [...new Set(places.map((place) => `${makerLabel(place.maker)}'s`))];
435
+ return `${and(makers)} models on Amazon Bedrock (${[...new Set(places.map((place) => place.geography))].join(", ")})`;
436
+ }
437
+ if (vendor !== "other")
438
+ return `${VENDORS[vendor].label}'s models`;
439
+ if (!given)
440
+ return "a mix of models";
441
+ return `${and([...new Set(pair.map((model) => `${makerLabel(model.split("/")[0])}'s`))])} models`;
442
+ }
443
+ /** The thinking levels a spec may end with - not part of a model given to init-config. */
444
+ const THINKING = /:(off|minimal|low|medium|high|xhigh|max)$/;
445
+ /** Why a model given cannot be written - or undefined when pi's catalogue has it as a chat model. */
446
+ function notInCatalogue(spec, catalogue) {
447
+ const slash = spec.indexOf("/");
448
+ if (slash <= 0)
449
+ return `${spec} is not provider/model-id - amazon-bedrock/global.xai.grok-4.7, say.`;
450
+ const [provider, id] = [spec.slice(0, slash), spec.slice(slash + 1)];
451
+ const known = catalogue.models(provider);
452
+ const model = known.find((entry) => entry.id === id);
453
+ if (model === undefined) {
454
+ if (THINKING.test(id) && known.some((entry) => entry.id === id.replace(THINKING, "")))
455
+ return `${spec} names a thinking level: give the model alone, ${spec.replace(THINKING, "")} - turri writes how hard each thinks.`;
456
+ if (known.length === 0)
457
+ return `${spec} is not in pi's model catalogue: pi knows no provider ${provider}.`;
458
+ const close = closestModels(id, known.filter((entry) => entry.type === "chat").map((entry) => entry.id));
459
+ return `${spec} is not in pi's model catalogue${close.length > 0 ? ` - did you mean ${close.map((other) => `${provider}/${other}`).join(", ")}?` : "."}`;
460
+ }
461
+ if (model.type !== "chat")
462
+ return `${spec} is pi's ${model.type} model, not a chat model: turri's agents need one to talk to.`;
463
+ return undefined;
464
+ }
465
+ /** Whose models a vendor's are: the provider its specs start with. */
466
+ const providerOf = (vendor) => (vendor === "other" ? undefined : vendor);
467
+ /**
468
+ * The models to write, and the vendor and Bedrock choice they are written for - or what is wrong with what was given:
469
+ * models given, checked against pi's catalogue; a Bedrock maker or geography turri has no pair of its own for, the
470
+ * catalogue's default pair; otherwise the vendor's own, as before, no catalogue read.
471
+ */
472
+ async function chosenModels(options) {
473
+ const catalogue = async () => options.catalogue ?? (await piModelCatalogue());
474
+ if (options.models !== undefined) {
475
+ const { harness, coding } = options.models;
476
+ const models = { harness, coding, strong: options.models.strong ?? harness };
477
+ const known = await catalogue();
478
+ const problems = [...new Set([models.harness, models.coding, models.strong])].map((spec) => notInCatalogue(spec, known)).filter((problem) => problem !== undefined);
479
+ if (problems.length > 0)
480
+ return { problem: problems.join("\n") };
481
+ const providers = [...new Set(Object.values(models).map((spec) => spec.split("/")[0]))];
482
+ const vendor = options.vendor ?? (providers.length === 1 && providers[0] in VENDORS ? providers[0] : "other");
483
+ const wrong = [...new Set(Object.values(models))].filter((spec) => providerOf(vendor) !== undefined && spec.split("/")[0] !== providerOf(vendor));
484
+ if (wrong.length > 0)
485
+ return { problem: `${wrong.join(", ")} ${wrong.length === 1 ? "is not" : "are not"} ${VENDORS[vendor].label}'s: with vendor ${vendor}, every model is ${vendor}/... - or leave the vendor out.` };
486
+ if (vendor !== "amazon-bedrock")
487
+ return { vendor, models, given: true };
488
+ const places = [models.harness, models.coding].map(bedrockPlaceOf);
489
+ const region = options.bedrock?.region ?? places[0].geography;
490
+ if (!places.some((place) => place.geography === region))
491
+ return { problem: `Neither model is served in ${region} on Bedrock: ${models.harness} and ${models.coding} are ${and([...new Set(places.map((place) => place.geography))])}'s.` };
492
+ const maker = options.bedrock?.maker ?? places[0].maker;
493
+ if (!places.some((place) => place.maker === maker))
494
+ return { problem: `Neither model is ${makerLabel(maker)}'s: ${models.harness} and ${models.coding}.` };
495
+ return { vendor, bedrock: { maker, region }, models, given: true };
496
+ }
497
+ const vendor = options.vendor ?? "other";
309
498
  if (vendor !== "amazon-bedrock")
310
- return `${VENDORS[vendor].label}'s`;
311
- const choice = bedrock ?? BEDROCK_DEFAULT;
312
- return `${VENDORS[choice.maker].label}'s on Amazon Bedrock (${choice.region})`;
499
+ return { vendor, models: modelsOf(vendor), given: false };
500
+ const bedrock = { maker: options.bedrock?.maker ?? BEDROCK_DEFAULT.maker, region: options.bedrock?.region ?? BEDROCK_DEFAULT.region };
501
+ if (hasPreset(bedrock))
502
+ return { vendor, bedrock, models: bedrockModels(bedrock), given: false };
503
+ const known = await catalogue();
504
+ const offer = bedrockMakers(known).find((entry) => entry.maker === bedrock.maker);
505
+ if (offer === undefined)
506
+ return { problem: `pi's catalogue has no models of ${makerLabel(bedrock.maker)}'s on Amazon Bedrock.` };
507
+ const models = bedrockDefaultModels(known, bedrock);
508
+ if (models === undefined)
509
+ return { problem: `Amazon Bedrock serves ${offer.label}'s models in ${and(offer.regions)}, not ${bedrock.region}.` };
510
+ return { vendor, bedrock, models, given: false };
313
511
  }
314
512
  /**
315
513
  * What a config.yaml in `folder` says that turri reads too - another tool's testing scripts, say - by turri's names
@@ -346,23 +544,26 @@ export function sharedWithLegacy(folder) {
346
544
  }
347
545
  /**
348
546
  * Write the quick start config.turri.yaml in `dir`, with what a config.yaml beside it says that turri reads too. One already
349
- * there keeps everything but its two models, which are set to the vendor's - `force` writes the whole of it again. Says
350
- * what it did.
547
+ * there keeps everything but its two models, which are set to the vendor's - or the ones given - and `force` writes the
548
+ * whole of it again. Says what it did; a model pi's catalogue does not have writes nothing and says so.
351
549
  */
352
- export function initConfig(dir, options = {}) {
550
+ export async function initConfig(dir, options = {}) {
551
+ const chosen = await chosenModels(options);
552
+ if ("problem" in chosen)
553
+ return { written: false, message: chosen.problem };
554
+ const { vendor, bedrock, models, given } = chosen;
353
555
  const folder = resolve(dir);
354
556
  // The old stacks' files, config.<stack>.yaml, copied to their new names; config.turri.yaml is written below.
355
557
  adoptLegacyConfig(folder, { projectFile: false });
356
558
  const path = join(folder, CONFIG_FILE);
357
- const vendor = options.vendor ?? "other";
358
559
  const existing = existsSync(path) && options.force !== true;
359
- const { harness, coding } = modelsOf(vendor, options.bedrock);
560
+ const { harness, coding } = models;
360
561
  const lines = modelLines(harness, coding);
361
562
  // A new file takes what a config.yaml beside it - another tool's, say - says that turri reads too: its testing scripts, say.
362
563
  const shared = existing ? { carried: [], left: [] } : sharedWithLegacy(folder);
363
564
  const config = existing
364
565
  ? withModels(readFileSync(path, "utf8"), lines)
365
- : shared.carried.reduce((text, [key, value]) => withTopLevelValue(text, key, value), quickStartConfig(vendor, options.bedrock));
566
+ : shared.carried.reduce((text, [key, value]) => withTopLevelValue(text, key, value), quickStartConfig(vendor, bedrock, models));
366
567
  writeFileSync(path, config);
367
568
  const here = resolve(process.cwd()) === folder;
368
569
  const [talk, code] = [harness, coding].map((model, at) => `${model}:${at === 0 ? HARNESS_THINKING : ROLE_THINKING.coding}`);
@@ -371,7 +572,7 @@ export function initConfig(dir, options = {}) {
371
572
  message: [
372
573
  "",
373
574
  `${brand.success("✓")} ${existing ? `Set the two models in ${brand.bold(path)}` : `Wrote ${brand.bold(path)}`}`,
374
- ` ${brand.muted(`with ${whose(vendor, options.bedrock)} models${existing ? " - everything else in it left as it was (--force writes it all again)" : ""}`)}`,
575
+ ` ${brand.muted(`with ${whose(vendor, models, given)}${existing ? " - everything else in it left as it was (--force writes it all again)" : ""}`)}`,
375
576
  "",
376
577
  ` ${brand.muted("you talk to")} ${talk}`,
377
578
  ` ${brand.muted("it codes")} ${code}`,
@@ -383,7 +584,7 @@ export function initConfig(dir, options = {}) {
383
584
  ]
384
585
  : []),
385
586
  "",
386
- ...signInText(vendor, options.bedrock),
587
+ ...signInText(vendor, bedrock, given ? models : undefined),
387
588
  "",
388
589
  brand.bold("Then"),
389
590
  ...(here ? [] : [` ${brand.accent(`cd ${folder}`)}`]),
@@ -397,12 +598,17 @@ export function initConfig(dir, options = {}) {
397
598
  };
398
599
  }
399
600
  const USAGE = [
400
- "usage: turri init-config [--vendor anthropic|openai|google|amazon-bedrock|other] [--maker anthropic|openai] [--region <geography>] [--force] [folder]",
601
+ "usage: turri init-config [--vendor anthropic|openai|google|amazon-bedrock|other] [--maker <maker>] [--region <geography>]",
602
+ " [--harness-model <spec> --coding-model <spec> [--judge-model <spec>]] [--force] [folder]",
401
603
  "",
402
604
  "Write a quick start config.turri.yaml in the folder, the current one by default: the model you talk to and the one that",
403
605
  "writes the code, from the vendor you use. Asked when --vendor is not given and this is a terminal; otherwise other -",
404
606
  "a mix of Anthropic and Google, the starting point for any provider pi supports. On amazon-bedrock, --maker and",
405
- "--region say whose models and where (anthropic: global, us, eu, jp, au; openai: global, us) - asked when not given.",
607
+ "--region say whose models and where - any maker and geography pi's catalogue has there (anthropic, openai, xai, meta,",
608
+ "...; global, us, eu, jp, au, apac, in, ca, us-gov, or in-region) - asked when not given, then which two, turri's own",
609
+ "pair or the catalogue's offered first. --harness-model and --coding-model name the two instead, as provider/model-id",
610
+ "without a thinking level - amazon-bedrock/global.xai.grok-4.7 - any pi's catalogue has; --judge-model the strong one",
611
+ "the judging examples name, the harness model unless given. The vendor, maker and region are read from them.",
406
612
  "A config.turri.yaml already there keeps everything but its two models; --force writes the whole of it again. A new",
407
613
  "one takes from a config.yaml beside it - another tool's, say - the keys turri reads too, its testing scripts say, and leaves",
408
614
  "config.yaml as it is.",
@@ -416,15 +622,20 @@ async function askUntil(rl, prompt, read, again) {
416
622
  console.log(again);
417
623
  }
418
624
  }
419
- /** Ask which vendor's models to use - a number or a name - and, for Amazon Bedrock, whose and where: what is not given. */
420
- async function askVendor(given) {
625
+ /** A model's price, as the list of models shows it: dollars a million tokens in and out. */
626
+ const priced = (model) => `$${model.cost.input}/$${model.cost.output} a million tokens in/out${model.reasoning ? "" : ", no reasoning"}`;
627
+ /**
628
+ * Ask which vendor's models to use - a number or a name - and, for Amazon Bedrock, whose and where, from pi's catalogue,
629
+ * then which two: the pair offered taken with Enter, or chosen from the maker's models there. What is given is not asked.
630
+ */
631
+ async function askVendor(given, catalogue) {
421
632
  const names = Object.keys(VENDORS);
422
633
  const rl = createInterface({ input: process.stdin, output: process.stdout });
423
634
  /** A question: a blank line before it, its choices numbered, each with what it is. */
424
635
  const ask = (question, choices) => {
425
636
  console.log(`\n${brand.bold(brand.accent(question))}`);
426
637
  const width = Math.max(...choices.map(([label]) => label.length)) + 3;
427
- choices.forEach(([label, what], index) => console.log(` ${brand.accent(`${index + 1}`)} ${label.padEnd(width)}${brand.muted(what)}`));
638
+ choices.forEach(([label, what], index) => console.log(` ${brand.accent(`${String(index + 1).padStart(String(choices.length).length)}`)} ${label.padEnd(width)}${brand.muted(what)}`));
428
639
  };
429
640
  const choose = (count) => `\n${brand.muted(`Choose 1-${count}:`)} `;
430
641
  try {
@@ -433,7 +644,7 @@ async function askVendor(given) {
433
644
  ask("Which vendor's models do you use?", names.map((name) => {
434
645
  const { label, harness, coding } = VENDORS[name];
435
646
  const what = name === "amazon-bedrock"
436
- ? "Anthropic's or OpenAI's models on AWS"
647
+ ? "Anthropic's, OpenAI's, xAI's, Meta's... models on AWS"
437
648
  : `${name === "other" ? "a mix - " : ""}${harness.split("/")[1]} to talk to, ${coding.split("/")[1]} to code`;
438
649
  return [label, what];
439
650
  }));
@@ -441,21 +652,47 @@ async function askVendor(given) {
441
652
  }
442
653
  if (vendor !== "amazon-bedrock")
443
654
  return { vendor };
655
+ const known = await catalogue();
656
+ const makers = bedrockMakers(known);
444
657
  let maker = given.maker;
445
658
  if (maker === undefined) {
446
- ask("Whose models, on Amazon Bedrock?", [
447
- ["Anthropic", "Claude Opus 5.5 to talk to, Claude Haiku 5.5 to code"],
448
- ["OpenAI", "GPT-6 Astra to talk to, GPT-6 Luna to code"],
449
- ]);
450
- maker = await askUntil(rl, choose(2), bedrockMakerOf, "Type 1 or 2, or anthropic or openai.");
659
+ ask("Whose models, on Amazon Bedrock?", makers.map((offer) => {
660
+ const own = offer.maker === "anthropic" || offer.maker === "openai" ? bedrockModels({ maker: offer.maker, region: "global" }) : undefined;
661
+ return [offer.label, own !== undefined ? `${own.harness.split(".").slice(2).join(".")} to talk to, ${own.coding.split(".").slice(2).join(".")} to code` : offer.regions.join(", ")];
662
+ }));
663
+ maker = await askUntil(rl, choose(makers.length), (answer) => bedrockMakerOf(answer, known), `Type a number from 1 to ${makers.length}, or a maker's name.`);
451
664
  }
452
- const regions = BEDROCK_REGIONS[maker];
453
- let region = given.region !== undefined ? bedrockRegionOf(maker, given.region) : undefined;
665
+ const regions = makers.find((offer) => offer.maker === maker)?.regions ?? [];
666
+ let region = given.region !== undefined ? bedrockRegionOf(maker, given.region, known) : undefined;
454
667
  if (region === undefined) {
455
- ask("Which region?", regions.map((entry) => [entry, entry === "global" ? "wherever Bedrock has room" : ""]));
456
- region = await askUntil(rl, choose(regions.length), (answer) => bedrockRegionOf(maker, answer), `Type a number from 1 to ${regions.length}, or one of ${regions.join(", ")}.`);
668
+ ask("Which region?", regions.map((entry) => [entry, entry === "global" ? "wherever Bedrock has room" : entry === BEDROCK_IN_REGION ? "the AWS region it is called in" : ""]));
669
+ region = await askUntil(rl, choose(regions.length), (answer) => bedrockRegionOf(maker, answer, known), `Type a number from 1 to ${regions.length}, or one of ${regions.join(", ")}.`);
457
670
  }
458
- return { vendor, bedrock: { maker, region } };
671
+ const bedrock = { maker, region };
672
+ const offered = bedrockDefaultModels(known, bedrock);
673
+ const models = bedrockModelsIn(known, bedrock);
674
+ if (offered === undefined || models.length < 2)
675
+ return { vendor, bedrock };
676
+ const bare = (spec) => spec.replace(/^amazon-bedrock\//, "");
677
+ console.log(`\n${brand.bold(brand.accent("Which models?"))}`);
678
+ console.log(` ${bare(offered.harness)} ${brand.muted("to talk to,")} ${bare(offered.coding)} ${brand.muted("to code")}`);
679
+ const keep = (await rl.question(`\n${brand.muted("Enter takes them; c chooses others:")} `)).trim().toLowerCase();
680
+ if (keep !== "c")
681
+ return { vendor, bedrock };
682
+ ask("The model you talk to - it writes the specs and plans the work", models.map((model) => [model.id, priced(model)]));
683
+ const pick = (fallback) => (answer) => {
684
+ const typed = answer.trim();
685
+ if (typed === "")
686
+ return fallback;
687
+ const model = (/^\d+$/.test(typed) ? models[Number(typed) - 1] : undefined) ?? models.find((entry) => entry.id === typed);
688
+ return model !== undefined ? `amazon-bedrock/${model.id}` : undefined;
689
+ };
690
+ const atOf = (spec) => models.findIndex((model) => `amazon-bedrock/${model.id}` === spec) + 1;
691
+ const again = `Type a number from 1 to ${models.length}, or Enter for the one offered.`;
692
+ const harness = await askUntil(rl, `\n${brand.muted(`Choose 1-${models.length}, Enter for ${atOf(offered.harness)}:`)} `, pick(offered.harness), again);
693
+ ask("The model that writes the code - a smaller, cheaper one", models.map((model) => [model.id, priced(model)]));
694
+ const coding = await askUntil(rl, `\n${brand.muted(`Choose 1-${models.length}, Enter for ${atOf(offered.coding)}:`)} `, pick(offered.coding), again);
695
+ return { vendor, bedrock, models: { harness, coding, ...(harness === offered.harness ? { strong: offered.strong } : {}) } };
459
696
  }
460
697
  finally {
461
698
  rl.close();
@@ -466,15 +703,22 @@ function flagOf(args, name) {
466
703
  const index = args.findIndex((arg) => arg === `--${name}` || arg.startsWith(`--${name}=`));
467
704
  if (index === -1)
468
705
  return { at: [] };
469
- return args[index].includes("=") ? { value: args[index].split("=")[1], at: [index] } : { value: args[index + 1], at: [index, index + 1] };
706
+ return args[index].includes("=") ? { value: args[index].slice(args[index].indexOf("=") + 1), at: [index] } : { value: args[index + 1], at: [index, index + 1] };
470
707
  }
471
- /** `turri init-config [--vendor <vendor>] [--maker <maker>] [--region <geography>] [--force] [folder]`. */
708
+ /** `turri init-config [--vendor <vendor>] [--maker <maker>] [--region <geography>] [--harness-model <spec> --coding-model <spec>] [--force] [folder]`. */
472
709
  export async function main(args) {
473
710
  if (args.includes("-h") || args.includes("--help")) {
474
711
  console.log(USAGE);
475
712
  return 0;
476
713
  }
477
- const flags = { vendor: flagOf(args, "vendor"), maker: flagOf(args, "maker"), region: flagOf(args, "region") };
714
+ const flags = {
715
+ vendor: flagOf(args, "vendor"),
716
+ maker: flagOf(args, "maker"),
717
+ region: flagOf(args, "region"),
718
+ harness: flagOf(args, "harness-model"),
719
+ coding: flagOf(args, "coding-model"),
720
+ judge: flagOf(args, "judge-model"),
721
+ };
478
722
  const taken = new Set(Object.values(flags).flatMap((flag) => flag.at));
479
723
  const rest = args.filter((arg, index) => !arg.startsWith("-") && !taken.has(index));
480
724
  const folder = rest[0] ?? process.cwd();
@@ -490,21 +734,35 @@ export async function main(args) {
490
734
  return 1;
491
735
  }
492
736
  }
737
+ // pi's catalogue, read once and only when it is needed: models named, or a maker or region beyond turri's own pair.
738
+ let read;
739
+ const catalogue = async () => (read ??= await piModelCatalogue());
740
+ const [harness, coding, judge] = [flags.harness.value, flags.coding.value, flags.judge.value];
741
+ if ((harness === undefined) !== (coding === undefined) || (judge !== undefined && harness === undefined)) {
742
+ console.error(`Name both models - --harness-model and --coding-model - or neither.\n\n${USAGE}`);
743
+ return 1;
744
+ }
745
+ const named = harness !== undefined && coding !== undefined ? { harness, coding, ...(judge !== undefined ? { strong: judge } : {}) } : undefined;
493
746
  let maker;
494
747
  if (flags.maker.value !== undefined) {
495
- maker = bedrockMakerOf(flags.maker.value);
748
+ const typed = flags.maker.value.trim().toLowerCase();
749
+ maker = bedrockMakerOf(typed, ["anthropic", "openai"].includes(typed) ? undefined : await catalogue());
496
750
  if (maker === undefined) {
497
- console.error(`Not one of Bedrock's makers here: ${flags.maker.value}. One of anthropic, openai.\n\n${USAGE}`);
751
+ console.error(`Not a maker pi's catalogue has on Amazon Bedrock: ${flags.maker.value}. One of ${bedrockMakers(await catalogue()).map((offer) => offer.maker).join(", ")}.\n\n${USAGE}`);
498
752
  return 1;
499
753
  }
500
754
  }
501
- const region = flags.region.value;
502
- if (region !== undefined && bedrockRegionOf(maker ?? BEDROCK_DEFAULT.maker, region) === undefined) {
503
- console.error(`Not a region ${VENDORS[maker ?? BEDROCK_DEFAULT.maker].label}'s models run in on Bedrock: ${region}. One of ${BEDROCK_REGIONS[maker ?? BEDROCK_DEFAULT.maker].join(", ")}.`);
504
- return 1;
755
+ const region = flags.region.value?.trim().toLowerCase();
756
+ if (region !== undefined && named === undefined) {
757
+ const of = maker ?? BEDROCK_DEFAULT.maker;
758
+ if (!hasPreset({ maker: of, region }) && bedrockRegionOf(of, region, await catalogue()) === undefined) {
759
+ const regions = bedrockMakers(await catalogue()).find((offer) => offer.maker === of)?.regions ?? [];
760
+ console.error(`Not a region ${makerLabel(of)}'s models run in on Bedrock: ${region}. One of ${regions.join(", ")}.`);
761
+ return 1;
762
+ }
505
763
  }
506
764
  const force = args.includes("--force");
507
- const asking = process.stdin.isTTY && process.stdout.isTTY;
765
+ const asking = process.stdin.isTTY && process.stdout.isTTY && named === undefined;
508
766
  let chosen;
509
767
  try {
510
768
  chosen = await chooseVendor();
@@ -518,14 +776,27 @@ export async function main(args) {
518
776
  throw error;
519
777
  }
520
778
  async function chooseVendor() {
779
+ if (named !== undefined) {
780
+ return {
781
+ ...(vendor !== undefined ? { vendor } : {}),
782
+ ...(maker !== undefined || region !== undefined ? { bedrock: { ...(maker !== undefined ? { maker } : {}), region: region ?? bedrockPlaceOf(named.harness).geography } } : {}),
783
+ models: named,
784
+ };
785
+ }
521
786
  return asking && (vendor === undefined || (vendor === "amazon-bedrock" && (maker === undefined || region === undefined)))
522
- ? await askVendor({ ...(vendor !== undefined ? { vendor } : {}), ...(maker !== undefined ? { maker } : {}), ...(region !== undefined ? { region } : {}) })
787
+ ? await askVendor({ ...(vendor !== undefined ? { vendor } : {}), ...(maker !== undefined ? { maker } : {}), ...(region !== undefined ? { region } : {}) }, catalogue)
523
788
  : {
524
789
  vendor: vendor ?? "other",
525
- ...(vendor === "amazon-bedrock" ? { bedrock: { maker: maker ?? BEDROCK_DEFAULT.maker, region: bedrockRegionOf(maker ?? BEDROCK_DEFAULT.maker, region ?? BEDROCK_DEFAULT.region) } } : {}),
790
+ ...(vendor === "amazon-bedrock" ? { bedrock: { maker: maker ?? BEDROCK_DEFAULT.maker, region: region ?? BEDROCK_DEFAULT.region } } : {}),
526
791
  };
527
792
  }
528
- const result = initConfig(folder, { force, vendor: chosen.vendor, ...(chosen.bedrock !== undefined ? { bedrock: chosen.bedrock } : {}) });
793
+ const result = await initConfig(folder, {
794
+ force,
795
+ ...(chosen.vendor !== undefined ? { vendor: chosen.vendor } : {}),
796
+ ...(chosen.bedrock !== undefined ? { bedrock: chosen.bedrock } : {}),
797
+ ...(chosen.models !== undefined ? { models: chosen.models } : {}),
798
+ ...(read !== undefined ? { catalogue: read } : {}),
799
+ });
529
800
  (result.written ? console.log : console.error)(result.message);
530
801
  return result.written ? 0 : 1;
531
802
  }