@tiens.nguyen/gu-cli 1.0.721 → 1.0.723

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/gu-repl.mjs CHANGED
@@ -24,7 +24,7 @@
24
24
  * Commands: /exit /model /reset /help Ctrl-C stops following the current run.
25
25
  */
26
26
  import { readFile, mkdir, writeFile, unlink, rename } from "node:fs/promises";
27
- import { existsSync, statSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { existsSync, statSync, readFileSync, writeFileSync, readdirSync, openSync, readSync, closeSync } from "node:fs";
28
28
  import { spawn } from "node:child_process";
29
29
  // FIRST, before anything that could itself fail on an old runtime: npm only WARNS about
30
30
  // `engines`, so an unsupported Node installs fine and then breaks somewhere unrelated.
@@ -73,6 +73,10 @@ import {
73
73
  answerBlock,
74
74
  updateRestartNote,
75
75
  describeInstallFailure,
76
+ CLI_UPDATE_SHELL_COMMAND,
77
+ isFreshPublishMiss,
78
+ freshPublishNote,
79
+ lazyModuleNames,
76
80
  inputZoneRows,
77
81
  INPUT_HEAD_ROWS,
78
82
  RAG_STORAGE,
@@ -494,11 +498,11 @@ const fmtTokens = (n) => {
494
498
  */
495
499
  const PICK_HINT = "↑/↓ then Enter · Esc to cancel";
496
500
  const PICK_MANY_HINT = "↑/↓ · space toggles · ctrl+a all · Enter saves · Esc cancels";
497
- // How many EXTRA coders /model will offer alongside the default (user, 2026-09-04). A cap keeps
498
- // that picker readable: a box with sixteen models would otherwise make switching coder a
499
- // sixteen-row scroll every time. Three is a working set — the default plus three alternatives —
500
- // not a technical limit; nothing downstream cares how long the list is.
501
- const MAX_ALT_CODERS = 3;
501
+ // How many EXTRA models one server offers alongside its default. Task #245 made it a rule, not a
502
+ // readability cap: a server URL offers AT MOST 3 models, default included (MAX_MODELS_PER_SERVER
503
+ // in coding-provider-setup.mjs and api/src/store/settingsTypes.ts — the API refuses a row that
504
+ // grows past it). A server saved with more before the rule keeps them: see extraModelRoom.
505
+ const MAX_ALT_CODERS = 2;
502
506
  const BULLET = "●"; // a neutral completed action (informational step)
503
507
  const CHECK = "✓"; // a step that succeeded
504
508
  const CROSS = "✗"; // a step that FAILED
@@ -795,8 +799,8 @@ const COMMANDS = [
795
799
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
796
800
  { name: "/s3", usage: "/s3 [test]", desc: "set up the S3 bucket the RAG index lives in, and check the AWS keys work" },
797
801
  { name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
798
- { name: "/save-settings", usage: "/save-settings [path]", desc: "write this account's coding providers, models and key NAMES to a file (no secrets)" },
799
- { name: "/load-settings", usage: "/load-settings [path]", desc: "read that file back on another machine — asks before it replaces anything" },
802
+ { name: "/save-settings", usage: "/save-settings [name|path]", desc: "write this account's providers, models and key NAMES to a file in your home folder (no secrets) — asks for a name, Enter keeps gu-settings.json" },
803
+ { name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
800
804
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
801
805
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
802
806
  { name: "/help", desc: "list these commands" },
@@ -1962,6 +1966,9 @@ function announceCliUpdate() {
1962
1966
  * The picker names the consequence rather than the version, because the version is already on
1963
1967
  * the button and the consequence is what the answer turns on.
1964
1968
  */
1969
+ /** How long to wait before retrying an install that hit a just-published version (ETARGET). */
1970
+ const UPDATE_RETRY_WAIT_MS = 15_000;
1971
+
1965
1972
  async function runUpdate() {
1966
1973
  const { cli, api } = availableUpdates;
1967
1974
  const rows = [];
@@ -2013,8 +2020,17 @@ async function runUpdate() {
2013
2020
  // stderr is captured rather than discarded so the reason can be shown. npm puts the
2014
2021
  // useful line (EPERM, EACCES, ETARGET) there, and "it failed" without it leaves the
2015
2022
  // user with nothing to act on — sudo and "close gu first" are different fixes.
2016
- const r = spawnSync("npm", ["i", "-g", "@tiens.nguyen/gu-cli@latest"],
2017
- { stdio: ["ignore", "ignore", "pipe"], shell: true, encoding: "utf8" });
2023
+ const install = () => spawnSync(CLI_UPDATE_SHELL_COMMAND,
2024
+ { stdio: ["ignore", "ignore", "pipe"], shell: true, encoding: "utf8" });
2025
+ let r = install();
2026
+ // JUST PUBLISHED: the update check sees a new version the moment it lands, npm's cache and
2027
+ // CDN a few minutes later — ETARGET in between. Wait once and retry before calling it a
2028
+ // failure; if npm still has not caught up, say THAT, not a raw npm error.
2029
+ if (isFreshPublishMiss(r)) {
2030
+ await new Promise((res) => setTimeout(res, UPDATE_RETRY_WAIT_MS));
2031
+ r = install();
2032
+ if (isFreshPublishMiss(r)) throw new Error(freshPublishNote(cli));
2033
+ }
2018
2034
  const failed = describeInstallFailure(r);
2019
2035
  if (failed) throw new Error(failed);
2020
2036
  });
@@ -4542,6 +4558,46 @@ async function saveSessionCodingKeyId(cwd) {
4542
4558
  await persistCodingChoiceToServer(cwd);
4543
4559
  }
4544
4560
 
4561
+ /** This folder's own key for a role (task #245), from the session-file cache. */
4562
+ async function loadSessionFolderKeyId(cwd, role) {
4563
+ const { roleOf } = await import("./server-keys.mjs");
4564
+ try {
4565
+ const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
4566
+ const v = raw?.[roleOf(role).field];
4567
+ return typeof v === "string" ? v.trim() : "";
4568
+ } catch {
4569
+ return "";
4570
+ }
4571
+ }
4572
+
4573
+ /**
4574
+ * THIS FOLDER'S OWN KEY FOR ONE ROLE (task #245) — the one place a folder's pick is written, for
4575
+ * all three roles. The session file is the offline cache; the database is the record, and its
4576
+ * write carries ONLY this role's field, which the API merges — so picking the agent's key can
4577
+ * never wipe the coder's. "" = back to the server's default.
4578
+ */
4579
+ async function saveFolderKeyPick(role, id) {
4580
+ const { roleOf } = await import("./server-keys.mjs");
4581
+ const field = roleOf(role).field;
4582
+ if (role === "agent") sessionAgentKeyId = id;
4583
+ else if (role === "embed") sessionEmbedKeyId = id;
4584
+ else sessionCodingKeyId = id;
4585
+ const cwd = resolve(process.cwd());
4586
+ try {
4587
+ await updateSessionFile(cwd, (raw) => {
4588
+ raw[field] = id;
4589
+ raw.updatedAt = new Date().toISOString();
4590
+ });
4591
+ } catch (err) {
4592
+ console.error(dim(`(key choice save failed: ${err.message})`));
4593
+ }
4594
+ try {
4595
+ await saveCodingChoice(folderHashFor(cwd), { [field]: id });
4596
+ } catch (err) {
4597
+ console.error(dim(`(key choice could not reach the database: ${err.message})`));
4598
+ }
4599
+ }
4600
+
4545
4601
  async function loadSessionCodingKeyId(cwd) {
4546
4602
  try {
4547
4603
  const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
@@ -5053,7 +5109,7 @@ async function reconfigureOllamaServer() {
5053
5109
  const ticks = await pickManyFromList(
5054
5110
  rest.map((m) => `${m.model}${am.formatSize(m.sizeBytes) ? dim(` ${am.formatSize(m.sizeBytes)}`) : ""}`),
5055
5111
  rest.map((m, i) => (already.has(m.model) ? i : -1)).filter((i) => i >= 0),
5056
- { max: MAX_ALT_CODERS }
5112
+ { max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m.model)).length) }
5057
5113
  );
5058
5114
  // null is Escape and cancels the command; an EMPTY list is a legitimate "offer none".
5059
5115
  if (ticks === null) {
@@ -5119,12 +5175,14 @@ async function reconfigureOllamaServer() {
5119
5175
  const keys = await fetchCodingKeys();
5120
5176
  const ps = await import("./coding-provider-setup.mjs");
5121
5177
  for (const u of cloudUrls) {
5122
- const row = ps
5123
- .existingBackendRows(fresh ?? {})
5124
- .find((b) => ps.ensureV1Root(String(b.url ?? "")).toLowerCase() === ps.ensureV1Root(u).toLowerCase());
5125
- if (row && !am.boundKeyFor(keys, row.id)) {
5178
+ const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), u);
5179
+ if (found && !am.boundKeyFor(keys, found.id)) {
5126
5180
  console.log(yellow(` ${hostOf(u)} is the Ollama cloud — it needs its own API key (the model list worked without one).`));
5127
- await askKeyForRow(row, hostOf(u));
5181
+ // The shared key prompt (task #245); the server's first key becomes its default.
5182
+ const { name, secret } = await askServerKey({ host: hostOf(u) });
5183
+ if (secret && !(await withPendingStatus("saving the key", () => addCodingKey(name, secret, found.id)))) {
5184
+ console.log(red(" couldn't save the key — add it with ctrl+k.\n"));
5185
+ }
5128
5186
  }
5129
5187
  }
5130
5188
  }
@@ -5234,7 +5292,8 @@ async function pickExtraModels(rest, preChecked = []) {
5234
5292
  const ticks = await pickManyFromList(
5235
5293
  rest,
5236
5294
  rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
5237
- { max: MAX_ALT_CODERS }
5295
+ // A server saved with more than the cap before #245 keeps them; it just cannot grow.
5296
+ { max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m)).length) }
5238
5297
  );
5239
5298
  // null is Escape and cancels; an EMPTY list is a legitimate "offer none".
5240
5299
  if (ticks === null) return null;
@@ -5323,7 +5382,7 @@ async function manageCodingProviders() {
5323
5382
  const providers = ps.manageableProviders(rows);
5324
5383
  // NOTHING TO MANAGE IS NOT A MENU. Straight to the add flow, exactly as before.
5325
5384
  if (providers.length === 0) {
5326
- await addCodingProvider();
5385
+ await addServer({ role: "coding", dialect: "openai" });
5327
5386
  return;
5328
5387
  }
5329
5388
 
@@ -5340,7 +5399,7 @@ async function manageCodingProviders() {
5340
5399
  return;
5341
5400
  }
5342
5401
  if (idx === ADD) {
5343
- await addCodingProvider();
5402
+ await addServer({ role: "coding", dialect: "openai" });
5344
5403
  return;
5345
5404
  }
5346
5405
 
@@ -5539,324 +5598,292 @@ async function removeCodingProvider(ps, rows, p) {
5539
5598
  }
5540
5599
 
5541
5600
  /**
5542
- * THE TWO ROLES A PROVIDER CAN BE ADDED FOR (task #225 phase 4).
5543
- *
5544
- * Adding a provider is the SAME job either way — validate the URL, take a key, ask the server
5545
- * what it has, save the row, bind the key, check it came back. That flow is written, tested and
5546
- * carries the eviction guards; duplicating it for the agent would mean two flows to fix every
5547
- * time one of them is wrong, which is how /model and web Settings drifted apart in #223.
5548
- *
5549
- * So the flow is shared and only the four genuine differences live here. Every one of them is a
5550
- * decision the user made, not an implementation detail:
5551
- *
5552
- * · WHAT THE MODEL IS FOR, which is the question asked and the word used afterwards.
5553
- * · HOW THE LIST IS READ. The coding role drops models the provider says are retired —
5554
- * offering one is offering a 404 several steps into a task. The agent role shows everything,
5555
- * because that is what was asked for ("show everything the provider lists"), with each row
5556
- * labelled instead.
5557
- * · WHETHER THE LIST IS FILTERED. Coding hides non-coding ids (asked for directly, 43 hidden
5558
- * on api.openai.com). Agent hides nothing. This asymmetry is deliberate — do not unify it.
5559
- * · WHERE THE ANSWER IS SAVED. A coding pick is THIS FOLDER's (reversible, and nobody asked
5560
- * for it to be account-wide). The agent model is a MACHINE setting — it is what `gu`
5561
- * talks to in every folder — so it is written to settings, which is where /agent-model has
5562
- * always written it.
5601
+ * ADD A MODEL SERVER — ONE SCREEN FOR THE CODER, THE AGENT AND THE EMBEDDER (task #245).
5602
+ *
5603
+ * Replaces three screens that asked the same things three ways (/model's add-provider,
5604
+ * /agent-model's Ollama server, /embed-model's Ollama and provider arms). Same questions, same
5605
+ * order, every role:
5606
+ *
5607
+ * 1. the server's URL — forgiving about what is pasted, and says what it used;
5608
+ * 2. its key — REUSED when the server already has one (a key belongs to the server, not the
5609
+ * model), asked only when the server needs one (an Ollama on this machine or the LAN never
5610
+ * does; a local vLLM may skip it). The FIRST key entered is what lists the models;
5611
+ * 3. its models, listed WITH that key — a real round trip, so a wrong URL or key fails HERE
5612
+ * rather than mid-turn. A refused key saves nothing;
5613
+ * 4. the model — coders filtered, embedders first, the agent sees everything (add-server.mjs);
5614
+ * 5. save the server row, then its key (the API makes a server's first key its default), then
5615
+ * the role's own setting.
5616
+ *
5617
+ * What differs per role or per server kind is decided in add-server.mjs, not here.
5618
+ *
5619
+ * role "coding" | "agent" | "embed"
5620
+ * dialect "openai" (an OpenAI-compatible /v1 server) | "ollama" (an Ollama: /api/tags)
5621
+ * url skip the URL question (a server picked from a list, or the embedder's current one)
5622
+ * model skip listing and picking (/embed-model's curated list already chose; `plan` with it)
5623
+ * newKey ask for a key even if the server has one (the stored one was refused)
5563
5624
  */
5564
- const PROVIDER_ROLES = {
5565
- coding: {
5566
- noun: "coding model",
5567
- question: "Which model writes the code?",
5568
- /** Retired ids dropped; the caller also filters non-coding ones in the picker. */
5569
- parse: (ps, am, body) => ps.parseProviderModels(body),
5570
- /** Extra models populate the row so /model can offer several from one provider. */
5571
- extras: true,
5572
- },
5573
- agent: {
5574
- noun: "agent model",
5575
- question: "Which model talks?",
5576
- // Nothing hidden — retired and non-chat ids are SUNK and LABELLED by agentModelsFromProvider.
5577
- // Shaped like parseProviderModels' answer so the caller needs no branch.
5578
- parse: (ps, am, body) => {
5579
- const rows = am.agentModelsFromProvider(body);
5580
- return { models: rows.map((r) => r.model), why: rows.length ? "" : "empty", total: rows.length, retired: 0 };
5581
- },
5582
- // The agent uses ONE model per machine; /agent-model probes the provider live every time, so
5583
- // there is nothing an extras list would add.
5584
- extras: false,
5585
- },
5586
- };
5587
-
5588
- async function addCodingProvider(roleName = "coding") {
5589
- const role = PROVIDER_ROLES[roleName] ?? PROVIDER_ROLES.coding;
5625
+ async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false } = {}) {
5626
+ const as = await import("./add-server.mjs");
5590
5627
  const ps = await import("./coding-provider-setup.mjs");
5591
- const am = await import("./agent-model-command.mjs");
5628
+ const sk = await import("./server-keys.mjs");
5592
5629
  const md = await import("./model-doctor.mjs");
5593
5630
  const { proxyFetch } = await import("./proxy-dispatcher.mjs");
5631
+ const R = as.serverRole(role);
5594
5632
 
5595
5633
  const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
5596
5634
  if (!settings) {
5597
- console.log(red(" couldn\'t reach the API to read your current settings.\n"));
5635
+ console.log(red(" couldn't reach the API to read your current settings.\n"));
5598
5636
  return;
5599
5637
  }
5600
5638
  // EVERY ROW THAT IS ALREADY THERE, including the synthesized one a pre-#123 account keeps in
5601
- // its legacy fields — writing an array without it would make the coder they have been using
5602
- // vanish from /model while its legacy fields sat there unread.
5639
+ // its legacy fields — writing an array without it would make that coder vanish from /model.
5603
5640
  const existing = ps.existingBackendRows(settings);
5604
- // WHAT IS BEING OFFERED RIGHT NOW, so we can tell afterwards whether saving cost anything.
5605
- //
5606
- // Read from the API's own answer rather than from `existing`, because `existing` is precisely
5607
- // what cannot be trusted: an API that omits agentCodingBackends from its settings view sends
5608
- // us one legacy row, and every provider added earlier is invisible here. codingChoices lists
5609
- // them on every API version, so a before/after comparison notices a loss that this client had
5610
- // no way to prevent. Failure is not fatal — it only means the check below stays quiet.
5611
- const beforeChoices = (
5612
- await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
5613
- )?.codingChoices ?? null;
5614
- if (existing.length >= ps.MAX_BACKENDS) {
5615
- console.log(yellow(` ${ps.MAX_BACKENDS} coding backends is the limit — remove one first.`));
5616
- console.log(GUTTER + dim("web Settings → Agent lists them; unchecking one frees a slot.\n"));
5617
- return;
5618
- }
5619
-
5620
- // OFFER THE OBVIOUS ANSWER. Reported after using this: "it says 'provider URL', please give
5621
- // default one, everyone know that right?" — https://api.openai.com/v1 is the address this whole
5622
- // category is named after. It is "" when that provider is already configured, because a default
5623
- // whose only possible outcome is "already configured here" invites a pointless Enter.
5624
- const dfltUrl = ps.defaultProviderUrl(existing);
5625
- console.log(dim(" Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
5626
- // THE ADDRESS GOES ABOVE, IN FULL, and the input row keeps a SHORT label. The frozen row is too
5627
- // narrow to hold a URL without truncating exactly the part worth reading — the same reason the
5628
- // Ollama server prompt puts its default on the line above (task #220 follow-up).
5629
- //
5630
- // …AND WHAT ENTER DOES IS SAID OUT LOUD. It used to cancel; it now accepts the default, and a
5631
- // prompt whose Enter key quietly changed meaning is the bug fixed in 46d1524e. Nothing is saved
5632
- // until the end of the flow either way, so an accidental Enter costs a question, not a provider.
5633
- // ESC IS THE CANCEL, and it is named here because a prompt that does not say so has no
5634
- // cancel as far as the reader is concerned — `type "no"` was a password you had to already
5635
- // know, and it was the only prompt in this flow that offered anything at all. Typing "no"
5636
- // still works (it is in fingers, and the tests pin it); it is simply no longer the only way
5637
- // out, and no longer the one advertised.
5638
- console.log(
5639
- dim(dfltUrl ? ` Enter uses ${dfltUrl} · Esc cancels\n` : " Esc cancels.\n")
5640
- );
5641
+ // What /model offers BEFORE, so a save that silently evicts a provider can be noticed (an API
5642
+ // older than row ids keys a backend by its KIND). Coding only — it is the list that can shrink.
5643
+ const beforeChoices = role === "coding"
5644
+ ? ((await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null))?.codingChoices ?? null)
5645
+ : null;
5641
5646
 
5642
- // --- 1. the address -------------------------------------------------------------------
5643
- let url = "";
5644
- let host = "";
5645
- let locality = "";
5646
- for (let attempt = 0; attempt < 3; attempt++) {
5647
- const typed = (await ask(" provider URL: ")).trim();
5648
- if (/^(no|n|skip|-)$/i.test(typed)) {
5649
- console.log(dim(" (cancelled)\n"));
5650
- return;
5651
- }
5652
- // Enter with nothing typed takes the default — or cancels when there is none to take.
5653
- const raw = typed || dfltUrl;
5654
- if (!raw) {
5655
- console.log(dim(" (cancelled)\n"));
5656
- return;
5657
- }
5658
- const r = ps.normalizeProviderUrl(raw);
5659
- if (!r.ok) {
5660
- for (const l of ps.describeUrlProblem(r.why, raw)) console.log(GUTTER + dim(l));
5661
- continue;
5662
- }
5663
- // ALREADY CONFIGURED is its own answer. Saving a second row for the same URL would give the
5664
- // picker two rows that are identical in everything a person can see.
5665
- const clash = existing.find((b) => ps.ensureV1Root(String(b.url ?? "")) === r.url);
5666
- if (clash && roleName === "agent") {
5667
- // FOR THE AGENT, "already configured" IS THE ANSWER, NOT A REFUSAL (#40). The provider and
5668
- // its key may well be there already; what is missing is the agent pointing at it. Sending the
5669
- // user to /model — the CODING picker — left no way to finish from here.
5670
- console.log(GUTTER + dim(`${r.host} is already configured — using it for the agent.`));
5671
- await pointAgentAtProvider(clash, r.host);
5647
+ // --- 1. the server --------------------------------------------------------------------
5648
+ let target = null;
5649
+ if (presetUrl) {
5650
+ target = as.serverTarget(dialect, presetUrl);
5651
+ if (!target.ok) {
5652
+ console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
5672
5653
  return;
5673
5654
  }
5674
- if (clash) {
5675
- console.log(GUTTER + dim(`${r.host} is already configured here — pick its model in /model.`));
5676
- continue;
5655
+ } else {
5656
+ const dflt = as.defaultServerUrl(role, dialect, settings, existing);
5657
+ console.log(dim(dialect === "ollama"
5658
+ ? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
5659
+ : " Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
5660
+ // The address goes ABOVE, in full, and Enter's meaning is said out loud (46d1524e).
5661
+ console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
5662
+ for (let attempt = 0; attempt < 3 && !target; attempt++) {
5663
+ const typed = await ask(" server URL: ");
5664
+ if (typed === null || as.isCancelWord(typed)) break;
5665
+ const raw = typed.trim() || dflt;
5666
+ if (!raw) break;
5667
+ const t = as.serverTarget(dialect, raw);
5668
+ if (!t.ok) {
5669
+ if (dialect === "openai") for (const l of ps.describeUrlProblem(t.why, raw)) console.log(GUTTER + dim(l));
5670
+ else console.log(GUTTER + dim(`"${raw}" is ${t.why} — try host:port, e.g. 127.0.0.1:11434`));
5671
+ continue;
5672
+ }
5673
+ // SAY WHAT WE ASSUMED: a scheme we supplied or a path we stripped is a guess, and a wrong
5674
+ // guess shows up later as a timeout that reads like a dead server.
5675
+ if (t.root !== raw && t.url !== raw) console.log(GUTTER + dim(`using ${t.root}`));
5676
+ target = t;
5677
5677
  }
5678
- url = r.url;
5679
- host = r.host;
5680
- locality = r.locality;
5681
- // SAY WHAT WE ASSUMED. A scheme we supplied is a guess, and a wrong guess shows up later as
5682
- // a timeout that reads like a dead server.
5683
- if (r.schemeAssumed || r.url !== raw) console.log(GUTTER + dim(`using ${r.url}`));
5684
- break;
5685
5678
  }
5686
- if (!url) {
5679
+ if (!target) {
5687
5680
  console.log(dim(" (nothing saved)\n"));
5688
5681
  return;
5689
5682
  }
5683
+ const host = target.host;
5690
5684
 
5691
- // --- 2. a name for the key, 3. the key ------------------------------------------------
5692
- // NOT DEFAULTED, by request. An empty name becomes "Unknown" at the API, which is the same
5693
- // thing every other add-a-key path produces — the URL default was asked for, this was not.
5694
- console.log("\n" + GUTTER + dim('A name just tells keys apart — "work", "personal".'));
5695
- const name = await askKeyName();
5696
- console.log(GUTTER + dim(SECRET_KEY_HINT));
5697
- const secret = (await askSecret(` API key${name ? ` for ${name}` : ""}: `)).trim();
5698
- if (!secret) {
5699
- console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
5685
+ // --- 2. the server's key --------------------------------------------------------------
5686
+ // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
5687
+ // entries identical in everything a person can see) — its keys come with it.
5688
+ const found = ps.providerRowForUrl(existing, target.url);
5689
+ // THE ROW CAP BEFORE ANY QUESTION about a new server: exceeding it is a silent 200 with no row,
5690
+ // and asking for a key and a model first would be the worst order to discover that in.
5691
+ if (!found && existing.length >= ps.MAX_BACKENDS) {
5692
+ console.log(yellow(` ${ps.MAX_BACKENDS} servers is the limit — remove one in /model → manage providers first.\n`));
5700
5693
  return;
5701
5694
  }
5702
- // DOES THAT LOOK LIKE A KEY? (asked for.) A shape check only — the provider decides validity —
5703
- // but "abc", "aaa" and a placeholder left in from a README are obvious on sight, and catching
5704
- // them here saves a turn that would otherwise die on a 401 several steps in. A local server is
5705
- // exempt: llama.cpp and friends accept any string, so "abc" is legitimate there.
5706
- const shape = ps.describeApiKeyShape(secret, { host, locality });
5707
- if (shape.level) {
5708
- console.log(GUTTER + (shape.level === "warn" ? yellow(shape.note) : dim(shape.note)));
5709
- // Only the clearly-wrong shapes are worth interrupting for, and only on a hosted provider
5710
- // where a bad key cannot possibly work.
5711
- if (shape.level === "warn" && !(await askYesNo("Use that key anyway?", false))) {
5712
- console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
5713
- return;
5714
- }
5695
+ const need = as.keyRequirement(target);
5696
+ let key = "";
5697
+ let keyName = "";
5698
+ let typedKey = false;
5699
+ if (found && !newKey) {
5700
+ // A KEY BELONGS TO THE SERVER: one already saved is used, whichever model it was saved for.
5701
+ const keys = await withPendingStatus(`reading the keys for ${host}`, () => fetchServerKeys(found.id));
5702
+ const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
5703
+ const inUse = sk.keyInUse(keys ?? [], found.id, folderKeyId);
5704
+ if (inUse && inUse.backendId === found.id) {
5705
+ key = (await fetchCodingKey(found.id)).key;
5706
+ if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
5707
+ }
5708
+ }
5709
+ const askKey = async () => {
5710
+ const t = await askServerKey({ host, optional: need === "optional" });
5711
+ key = t.secret;
5712
+ keyName = t.name;
5713
+ typedKey = Boolean(t.secret);
5714
+ return t.secret || need !== "required";
5715
+ };
5716
+ if (!key && (need !== "none" || newKey)) {
5717
+ if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
5718
+ if (!(await askKey())) return;
5715
5719
  }
5716
5720
 
5717
- // --- 4. ASK THE SERVER WHAT IT HAS, with the key ---------------------------------------
5718
- // A REAL ROUND TRIP, not a ping (the /doctor lesson): a bad URL or key has to fail HERE, where
5719
- // it is one line of explanation, rather than mid-turn as a coder that silently dropped to the
5720
- // chat model. A 401 at this point is the single most useful answer this flow can give.
5721
- const probe = await withPendingStatus(`asking ${host} what it has`, async () => {
5722
- try {
5723
- const res = await proxyFetch(ps.modelsUrlFor(url), {
5724
- headers: { Authorization: `Bearer ${secret}` },
5725
- signal: AbortSignal.timeout(12_000),
5726
- });
5727
- if (!res.ok) return { status: res.status };
5728
- return { body: await res.json().catch(() => null) };
5729
- } catch (err) {
5730
- return { err };
5731
- }
5732
- });
5721
+ // --- 3. its models, listed with that key ----------------------------------------------
5733
5722
  let models = [];
5734
- if (probe.status || probe.err) {
5735
- const d = ps.describeProbeFailure(probe);
5736
- for (const l of d.lines) console.log(GUTTER + (d.fatal ? red(l) : yellow(l)));
5737
- // FATAL MEANS THE KEY WAS REJECTED, and storing a credential the server has already refused
5738
- // would be storing a problem for later. Everything else is survivable: a server that will
5739
- // not list its models is not a server that cannot write code, and blocking here would make
5740
- // a perfectly good provider unenterable.
5741
- if (d.fatal) {
5742
- console.log(GUTTER + dim("nothing was saved — check the key and try again.\n"));
5743
- return;
5744
- }
5745
- console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
5746
- } else {
5747
- const parsed = role.parse(ps, am, probe.body);
5748
- models = parsed.models;
5749
- for (const l of ps.describeModelProblem(parsed.why, { host, total: parsed.total })) {
5750
- console.log(GUTTER + dim(l));
5723
+ if (!presetModel) {
5724
+ const list = () =>
5725
+ withPendingStatus(`asking ${host} what it has`, async () => {
5726
+ const req = as.listRequest(target, key);
5727
+ try {
5728
+ const res = await proxyFetch(req.url, { headers: req.headers, signal: AbortSignal.timeout(12_000) });
5729
+ return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
5730
+ } catch (err) {
5731
+ return { err };
5732
+ }
5733
+ });
5734
+ let probe = await list();
5735
+ // THE STORED KEY WAS REFUSED — ask for the right one once, rather than saving nothing.
5736
+ if ((probe.status === 401 || probe.status === 403) && !typedKey && key) {
5737
+ console.log(yellow(` ${host} refused the stored key (HTTP ${probe.status}) — enter the right one.`));
5738
+ if (!(await askKey()) || !key) return;
5739
+ probe = await list();
5740
+ }
5741
+ if (probe.status || probe.err) {
5742
+ const d = ps.describeProbeFailure(probe);
5743
+ for (const l of d.lines) console.log(GUTTER + (d.fatal ? red(l) : yellow(l)));
5744
+ // FATAL = THE KEY WAS REFUSED: storing a credential the server already rejected is storing
5745
+ // a problem for later. Anything else is survivable — the model id can be typed.
5746
+ if (d.fatal) {
5747
+ console.log(GUTTER + dim("nothing was saved — check the key and try again.\n"));
5748
+ return;
5749
+ }
5750
+ console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
5751
+ } else {
5752
+ const parsed = as.modelsForRole(role, target.dialect, probe.body);
5753
+ models = parsed.models;
5754
+ for (const l of ps.describeModelProblem(parsed.why, { host, total: parsed.total })) console.log(GUTTER + dim(l));
5755
+ const rn = ps.retiredNote(parsed.retired, host);
5756
+ if (rn) console.log(GUTTER + dim(rn));
5757
+ if (models.length) console.log(GUTTER + dim(`${host} offers ${models.length} model${models.length === 1 ? "" : "s"}`));
5751
5758
  }
5752
- // SAID, not silently dropped: a shorter list with no explanation reads as a provider with
5753
- // fewer models, which is the wrong story.
5754
- const rn = ps.retiredNote(parsed.retired, host);
5755
- if (rn) console.log(GUTTER + dim(rn));
5759
+ }
5760
+
5761
+ // --- 4. the model ---------------------------------------------------------------------
5762
+ let model = presetModel;
5763
+ const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
5764
+ if (!model) {
5756
5765
  if (models.length) {
5757
- console.log(GUTTER + dim(`${host} offers ${models.length} model${models.length === 1 ? "" : "s"}`));
5766
+ const am = await import("./agent-model-command.mjs");
5767
+ const current = role === "agent" ? am.currentAgentModel(settings) : role === "embed" ? ec.currentEmbedModel(settings) : sessionCodingLabel;
5768
+ model = R.filterCoders
5769
+ ? await chooseCodingModelFrom(ps, models, R.question, 0)
5770
+ : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)));
5771
+ if (model === null) {
5772
+ console.log(dim(" (cancelled — nothing was saved)\n"));
5773
+ return;
5774
+ }
5775
+ } else {
5776
+ model = String((await ask(" model id: ")) ?? "").trim();
5777
+ }
5778
+ if (!model) {
5779
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
5780
+ return;
5758
5781
  }
5759
5782
  }
5760
-
5761
- // --- 5. which model ---------------------------------------------------------------------
5762
- // THE LIST IS A SUGGESTION. Typing an id is always available, because a listed model may be
5763
- // unusable (a reasoning model that puts code in the wrong channel, or one that spells the
5764
- // <code> block differently and loses its reasoning silently) and an unlisted one may be fine.
5765
- let model = "";
5766
- if (models.length) {
5767
- model =
5768
- roleName === "agent"
5769
- // UNFILTERED, and it must stay that way. chooseCodingModelFrom applies the non-coding
5770
- // filter, which is right for a coder and the opposite of what was asked for here.
5771
- ? await chooseFromModelList(models, role.question, 0)
5772
- : await chooseCodingModelFrom(ps, models, role.question, 0);
5773
- if (model === null) {
5774
- console.log(dim(" (cancelled — nothing was saved)\n"));
5783
+ // THE EMBEDDER'S RECOGNITION WALL: a different model invalidates every index, so the projects
5784
+ // that stop working are named BEFORE anything is saved. The same model is a pure server move.
5785
+ let plan = presetPlan;
5786
+ if (role === "embed" && !presetModel) {
5787
+ const current = ec.currentEmbedModel(settings);
5788
+ if (!ec.sameEmbedModel(model, current)) {
5789
+ const sw = await confirmEmbedSwitch(ec, current, model);
5790
+ if (!sw.changed) return;
5791
+ plan = sw.plan;
5792
+ } else if (as.serverTarget(target.dialect, String(settings.ragEmbedUrl ?? "")).root === target.root && !typedKey) {
5793
+ console.log(dim(` already embedding with ${model} on ${host} — nothing changed.\n`));
5775
5794
  return;
5776
5795
  }
5777
- } else {
5778
- model = (await ask(" model id: ")).trim();
5779
5796
  }
5780
- if (!model) {
5781
- console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
5782
- return;
5797
+ // MORE THAN ONE MODEL from a NEW server, for the coder: the default is what runs, these are the
5798
+ // others /model may switch to. At most MAX_MODELS_PER_SERVER in all (the API refuses more).
5799
+ let extras = [];
5800
+ if (R.extras && !found && models.length > 1) {
5801
+ extras = await pickExtraModels(models.filter((m) => m !== model));
5802
+ if (extras === null) {
5803
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
5804
+ return;
5805
+ }
5783
5806
  }
5784
5807
 
5785
- // --- 5b. MORE THAN ONE MODEL, up to the same cap the Ollama flow uses ------------------
5786
- // Asked for directly: a provider list is eighty ids long and one of them is not enough.
5787
- // The default above is what RUNS; these are the others /model may switch to without coming
5788
- // back through here. Skipped when the probe returned nothing to choose from.
5789
- const extras = role.extras ? await pickExtraModels(models.filter((m) => m !== model)) : [];
5790
- if (extras === null) {
5791
- console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
5792
- return;
5808
+ // --- 5. save: the server, its key, then the role --------------------------------------
5809
+ let rowId = found?.id ?? "";
5810
+ let rows = existing;
5811
+ // A NEW ROW ONLY WHEN SOMETHING NEEDS ONE: the coder is picked through its row, and a key is
5812
+ // bound to one. A keyless Ollama on this machine or the LAN for the agent or the embedder needs
5813
+ // neither — the role's own setting holds its URL — and a row would put it in /model as a coder.
5814
+ const needRow = role === "coding" || typedKey;
5815
+ if (!found && needRow) {
5816
+ rowId = ps.mintProviderId(existing.map((b) => String(b.id ?? "")));
5817
+ const { row } = as.rowForSave(role, { id: rowId, kind: target.dialect === "ollama" ? "ollama" : "openai", url: target.url, model, extras });
5818
+ rows = [...existing, row];
5819
+ } else {
5820
+ const r = as.rowForSave(role, { existing: found.row, model });
5821
+ if (r.full) {
5822
+ console.log(yellow(` ${host} already offers ${ps.MAX_MODELS_PER_SERVER} models — remove one in /model → manage providers first.\n`));
5823
+ return;
5824
+ }
5825
+ if (r.changed) rows = existing.map((b) => (b === found.row ? r.row : b));
5793
5826
  }
5794
-
5795
- // --- 6. save: the backend first, then the key that belongs to it -----------------------
5796
- const backendId = ps.mintProviderId(existing.map((b) => String(b.id ?? "")));
5797
- const rows = [...existing, { ...ps.newProviderRow({ id: backendId, url, model }), models: extras }];
5798
- const saved = await withPendingStatus("saving the provider", () =>
5799
- md.saveWorkerSettings(apiBase, workerKey, { agentCodingBackends: rows })
5800
- );
5801
- if (!saved) {
5802
- console.log(red(" couldn\'t save that to this machine — nothing changed."));
5803
- console.log(GUTTER + dim("if this machine\'s local API is old, restart it with `gu api restart`.\n"));
5804
- return;
5827
+ if (rows !== existing) {
5828
+ const ok = await withPendingStatus(`saving ${host}`, () => md.saveWorkerSettings(apiBase, workerKey, { agentCodingBackends: rows }));
5829
+ if (!ok) {
5830
+ console.log(red(` couldn't save ${host} — nothing changed.`));
5831
+ console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
5832
+ return;
5833
+ }
5834
+ if (!found && role !== "coding" && as.serverRole(role).rowModel) console.log(GUTTER + dim(`${host} is also offered in /model now.`));
5805
5835
  }
5806
- const keyId = await withPendingStatus("saving the key", () =>
5807
- addCodingKey(name, secret, backendId)
5808
- );
5809
- if (!keyId) {
5810
- // The provider IS saved, so say that rather than implying the whole thing failed — and say
5811
- // what is missing, because without a key this backend drops its coder on every turn.
5812
- console.log(yellow(` ${host} was saved, but its key was not.`));
5813
- console.log(GUTTER + dim("pick its model in /model, then press ctrl+k to add the key.\n"));
5814
- return;
5836
+ if (typedKey) {
5837
+ const id = await withPendingStatus("saving the key", () => addCodingKey(keyName, key, rowId));
5838
+ if (!id) {
5839
+ console.log(yellow(` ${host} was saved, but its key was not — nothing else changed.`));
5840
+ console.log(GUTTER + dim("try again, or add the key with ctrl+k.\n"));
5841
+ return;
5842
+ }
5843
+ // A key typed for a server that already had keys was typed because there was none usable or
5844
+ // the stored one was refused: it is the server's key from now on, for every role and folder.
5845
+ if (found) await setServerDefaultKey(rowId, id);
5815
5846
  }
5847
+ const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key`) : "";
5816
5848
 
5817
- // --- 7. use it in THIS folder ----------------------------------------------------------
5818
- // ASK THE API WHAT THE PICK IS CALLED. THE TERMINAL MUST NOT COMPUTE IT.
5819
- //
5820
- // Reported, and it made a freshly-added provider unusable on the first turn:
5821
- // ✗ "prov_9ez1q69s::gpt-5.1-codex-mini" is not in your allowed coding models
5822
- // The wire id belongs to the API — codingModelChoiceList decides whether it is a bare model
5823
- // name or `${rowId}::${model}`, and an API that predates row ids answers `openai::${model}`
5824
- // for the very row this flow just saved. Guessing it here meant storing a pick that the next
5825
- // turn could only reject, seconds after configuring the thing.
5826
- //
5827
- // So: re-read the choices and take the id the server actually offers. Matched on the MODEL and
5828
- // the BACKEND — by id when the API is new enough to report one, else by URL, which is what
5829
- // identifies a row to an older API.
5830
- // --- 7a. THE AGENT ROLE SAVES A MACHINE SETTING AND IS DONE (task #225 phase 4) ----------
5831
- //
5832
- // The rest of this function is about the CODING pick: which wire id the API offers for it, and
5833
- // storing that id on the folder. None of it applies here. The agent model is not a folder
5834
- // choice — it is what `gu` talks to in every folder on this machine — so it goes to
5835
- // settings, which is where /agent-model has always written it.
5836
- //
5837
- // Written as URL + NAME rather than a row id because that is the pair app.ts reads
5838
- // (buildAgentChatJobPayload), and phase 1 resolves the key by matching that URL back to the
5839
- // provider row we just saved. One representation, already proven.
5840
- if (roleName === "agent") {
5841
- const ok = await withPendingStatus("pointing the agent model at it", () =>
5842
- md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: url, agentModelName: model })
5843
- );
5844
- if (!ok) {
5845
- // The PROVIDER is saved — say so, or this reads as the whole thing having failed and the
5846
- // user re-enters a key that is already stored.
5849
+ if (role === "agent") {
5850
+ // A MACHINE SETTING, not a folder pick: the agent is what gu talks to in every folder. Saved
5851
+ // as URL + NAME, the pair app.ts matches back to the server row to find the key.
5852
+ if (await saveAgentModel(target.roleUrl("agent"), model, host)) {
5853
+ if (keyLine) console.log(keyLine + "\n");
5854
+ console.log(GUTTER + dim("the banner shows the new model next time gu starts.\n"));
5855
+ } else if (rows !== existing || typedKey) {
5856
+ // The SERVER landed — say so, or the user re-enters a key that is already stored.
5847
5857
  console.log(yellow(` ${host} was saved, but the agent model was not pointed at it.`));
5848
5858
  console.log(GUTTER + dim("run /agent-model to finish, or check the API is reachable.\n"));
5849
- return;
5850
5859
  }
5851
- console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
5852
- console.log(dim(` API key ${name || "Unknown"} ····${secret.slice(-4)} · this machine\n`));
5853
- // Restart advice, because the banner and the running daemon both read this at startup: a
5854
- // machine that keeps talking to the old model after a successful save looks like the save
5855
- // failed, and that is the one thing this line prevents.
5856
- console.log(GUTTER + dim("the banner shows the new model next time gu starts.\n"));
5857
5860
  return;
5858
5861
  }
5862
+ if (role === "embed") {
5863
+ // ragEmbedKeyId "" — the server's own keys decide now (its default, or this folder's pick).
5864
+ const fields = { ragEmbedUrl: target.roleUrl("embed"), ragEmbedModel: model, ragEmbedKeyId: "" };
5865
+ if (!(await saveEmbedSettings(fields))) return;
5866
+ if (plan) announceEmbedSaved({ chosen: model, plan, url: target.roleUrl("embed") });
5867
+ else {
5868
+ console.log(accent(` ✓ embedding server → ${host}`));
5869
+ console.log(dim(` ${model} did not change, so no knowledge base rebuilds — the same model just answers from ${host}.\n`));
5870
+ }
5871
+ return;
5872
+ }
5873
+ await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName });
5874
+ }
5859
5875
 
5876
+ /**
5877
+ * The coder just added becomes THIS FOLDER's pick (not the account default — per-folder is
5878
+ * reversible and nobody asked for account-wide). Split out of addServer because only the coder
5879
+ * has it, and it carries the two checks that were learned the hard way:
5880
+ *
5881
+ * · ASK THE API WHAT THE PICK IS CALLED. The wire id belongs to the API (a bare model or
5882
+ * `${rowId}::${model}`); guessing it here once stored a pick the next turn rejected.
5883
+ * · DID SAVING EVICT ANOTHER PROVIDER? An API older than row ids keys a backend by its KIND,
5884
+ * so a second openai row replaced the first with a 200. Said BEFORE the success line.
5885
+ */
5886
+ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName }) {
5860
5887
  const cwd = resolve(process.cwd());
5861
5888
  const after = await withPendingStatus("checking it is offered", () =>
5862
5889
  fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
@@ -5866,28 +5893,13 @@ async function addCodingProvider(roleName = "coding") {
5866
5893
  (c) => c.model === model && (c.backendId ? c.backendId === backendId : sameHost(c.url, url))
5867
5894
  );
5868
5895
  if (!offered) {
5869
- // SAVED BUT NOT OFFERED. Pinning the folder to a guess here is what produced the reported
5870
- // failure; say what happened instead, and leave the folder on a coder that works.
5871
5896
  console.log(yellow(` ${host} was saved, but this API is not offering ${model} yet.`));
5872
5897
  console.log(GUTTER + dim("run /model — if it is missing there too, the API may be older than"));
5873
5898
  console.log(GUTTER + dim("this feature; restart it with `gu api restart`.\n"));
5874
5899
  return;
5875
5900
  }
5876
- // DID SAVING THIS ONE EVICT ANOTHER? (reported live)
5877
- //
5878
- // The check above asks only whether the model just added is offered — and it is, which is
5879
- // exactly why the loss was silent: "whenever i add or change an openai-compatible provider, it
5880
- // replace the old one in this list". An API older than row ids keys a backend by its KIND, so a
5881
- // second openai row collides with the first and the first disappears. The write answers 200,
5882
- // the new provider works, and nothing says the old one is gone.
5883
- //
5884
- // Printed BEFORE the success line, because it is the more important half of what just
5885
- // happened — and the folder's pick still goes through, since the provider that was added is
5886
- // genuinely usable and refusing to select it would help nobody.
5887
- // Two questions, and they catch different failures. providerUrlsLost asks whether a row WE
5888
- // SENT failed to land (an old API colliding two rows on their kind). providersNoLongerOffered
5889
- // asks whether anything that used to be offered has gone — including a provider this client
5890
- // never saw, which is the shape the reported bug actually took.
5901
+ // Two questions, and they catch different failures: a row WE SENT failed to land, or something
5902
+ // that used to be offered has gone (including a provider this client never saw).
5891
5903
  const lost = Array.from(
5892
5904
  new Set([
5893
5905
  ...ps.providerUrlsLost(rows, after?.codingChoices),
@@ -5898,32 +5910,27 @@ async function addCodingProvider(roleName = "coding") {
5898
5910
  console.log(yellow(` ⚠ this API dropped ${lost.length === 1 ? "a provider" : `${lost.length} providers`} when saving:`));
5899
5911
  for (const u of lost) console.log(GUTTER + dim(` ${u}`));
5900
5912
  if (ps.apiKeysBackendsByKind(after?.codingChoices)) {
5901
- // SAY WHY, not just what. "Restart your API" on its own is an instruction without a
5902
- // reason, and this one has now been given three times without being acted on.
5903
5913
  console.log(GUTTER + dim("this API identifies a backend by its KIND, so it holds ONE"));
5904
5914
  console.log(GUTTER + dim("OpenAI-compatible provider at a time. A newer one keeps several."));
5905
5915
  }
5906
5916
  console.log(GUTTER + dim("restart it with `gu api restart`, then add that provider again.\n"));
5907
5917
  }
5908
- // THE FOLDER\'S CHOICE, NOT THE ACCOUNT DEFAULT. Adding a provider must not re-point every
5909
- // other folder at it — per-folder is reversible and account-wide is not, and nobody asked for
5910
- // the second thing. agentCodingDefaultBackendId is deliberately untouched.
5911
5918
  sessionCodingModel = offered.id;
5912
5919
  sessionCodingLabel = model;
5913
5920
  sessionCodingUrl = url;
5914
- sessionCodingKeyId = keyId;
5915
- sessionApiKeyTail = secret.slice(-4); // store the tail for the bottom bar
5916
- keyChoicePending = false; // the provider's own key was just bound to this folder
5921
+ // The folder follows the SERVER's key (task #245) — its default, which a first key already is.
5922
+ sessionCodingKeyId = "";
5923
+ sessionApiKeyTail = key ? key.slice(-4) : "";
5924
+ keyChoicePending = false;
5917
5925
  await saveSessionCoding(cwd);
5918
5926
  await saveSessionCodingKeyId(cwd);
5919
- // The key question for this coder is now answered — leaving the flag set would warn about a
5920
- // key that was just entered, which reads as the save having failed.
5927
+ // The key question for this coder is answered — leaving the flag set would warn about a key
5928
+ // that was just entered, which reads as the save having failed.
5921
5929
  codingKeyMissingKnown = false;
5922
5930
  console.log(accent(` ✓ coding model → ${model}`) + dim(` · ${host}`));
5923
- console.log(dim(` API key ${name || "Unknown"} ····${secret.slice(-4)} · this folder only\n`));
5924
- // A HOSTED PROVIDER NEEDS ITS WINDOW SET (requirement 11). With nothing set gu assumes
5925
- // 24,000 prompt tokens and compacts there — on a large hosted model that throws history away
5926
- // every turn and produces a bill with no explanation.
5931
+ if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key\n`));
5932
+ // A HOSTED SERVER NEEDS ITS WINDOW SET: with nothing set gu assumes 24,000 prompt tokens and
5933
+ // compacts there — on a large hosted model that throws history away every turn.
5927
5934
  if (ps.shouldMentionContextWindow(locality)) {
5928
5935
  console.log(GUTTER + dim("tip: /context-window — hosted servers do not report their size,"));
5929
5936
  console.log(GUTTER + dim(" so gu assumes 24,000 prompt tokens and compacts there.\n"));
@@ -6142,20 +6149,31 @@ async function chooseModel() {
6142
6149
  * NOT THE CURRENT CHOICE. Which model this account codes with stays as it is on the machine being
6143
6150
  * loaded into: a file that moves your models should not quietly move your coder too.
6144
6151
  */
6145
- const SETTINGS_FILE_DEFAULT = join(homedir(), "gu-settings.json");
6146
-
6147
- /** The path a command was given, or the default. Quotes stripped: people paste paths with them. */
6148
- function settingsPathFrom(line, word) {
6149
- const typed = line.slice(word.length).trim().replace(/^['"]|['"]$/g, "");
6150
- if (!typed) return SETTINGS_FILE_DEFAULT;
6151
- const expanded = typed.startsWith("~/") ? join(homedir(), typed.slice(2)) : typed;
6152
- return resolve(expanded);
6152
+ /** What followed the command word, or "" — the name/path rules are settingsFilePath's. */
6153
+ function settingsArg(line, word) {
6154
+ return line.slice(word.length).trim();
6153
6155
  }
6154
6156
 
6155
6157
  async function runSaveSettingsCommand(line) {
6156
6158
  const st = await import("./settings-transfer.mjs");
6157
- const file = settingsPathFrom(line, "/save-settings");
6159
+ const home = homedir();
6158
6160
  console.log("");
6161
+ // THE FILE NAME, IN THE DEFAULT FOLDER. The home directory is where it goes; all anyone should
6162
+ // have to type is a name — and Enter keeps gu-settings.json, as before.
6163
+ let typed = settingsArg(line, "/save-settings");
6164
+ if (!typed) {
6165
+ const answer = await ask(` file name in ${home} [${st.DEFAULT_SETTINGS_NAME}]: `);
6166
+ if (answer === null) {
6167
+ console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
6168
+ return;
6169
+ }
6170
+ typed = answer.trim();
6171
+ }
6172
+ let file = st.settingsFilePath(typed, home);
6173
+ if (!file) {
6174
+ console.log(GUTTER + dim(" (cancelled — nothing was written)\n"));
6175
+ return;
6176
+ }
6159
6177
  const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6160
6178
  if (!settings) {
6161
6179
  console.log(GUTTER + red(` couldn't reach the API at ${apiBase} — nothing was written.\n`));
@@ -6163,18 +6181,23 @@ async function runSaveSettingsCommand(line) {
6163
6181
  }
6164
6182
  const keys = (await fetchCodingKeys()) ?? [];
6165
6183
  const doc = st.buildTransfer({ settings, keys, host: hostname() });
6166
- if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name) {
6167
- console.log(GUTTER + dim(" this account has no coding providers and no agent model set — nothing to save.\n"));
6184
+ if (!doc.codingBackends.length && !doc.agentModel.url && !doc.agentModel.name && !doc.embedModel.url && !doc.embedModel.name) {
6185
+ console.log(GUTTER + dim(" this account has no coding providers, agent model or embedding model set — nothing to save.\n"));
6168
6186
  return;
6169
6187
  }
6170
- // ASK BEFORE OVERWRITING. The default path is one everybody shares, so the second machine to
6171
- // save would otherwise eat the first machine's file without a word.
6172
- if (existsSync(file)) {
6173
- const ok = (await ask(`${file} exists — overwrite it? [y/N] `)).trim().toLowerCase();
6174
- if (ok !== "y" && ok !== "yes") {
6188
+ // ASK BEFORE OVERWRITING — and let a NEW NAME be the answer. The default path is one everybody
6189
+ // shares, so the second machine to save would otherwise eat the first machine's file; "no" alone
6190
+ // left nowhere to go but typing the whole command again with a path.
6191
+ while (existsSync(file)) {
6192
+ const answer = await ask(` ${file} exists — overwrite it? [y/N], or type a new name: `);
6193
+ const a = String(answer ?? "").trim();
6194
+ if (/^(y|yes)$/i.test(a)) break;
6195
+ const next = a && !/^(n|no)$/i.test(a) ? st.settingsFilePath(a, home) : "";
6196
+ if (!next) {
6175
6197
  console.log(GUTTER + dim(" left it alone.\n"));
6176
6198
  return;
6177
6199
  }
6200
+ file = next;
6178
6201
  }
6179
6202
  try {
6180
6203
  // 0600: it names your providers and your key names. Not secret, but nobody else's business.
@@ -6183,19 +6206,36 @@ async function runSaveSettingsCommand(line) {
6183
6206
  console.log(GUTTER + red(` could not write ${file}: ${e instanceof Error ? e.message : e}\n`));
6184
6207
  return;
6185
6208
  }
6186
- const models = doc.codingBackends.reduce((n, b) => n + b.models.length, 0);
6187
6209
  console.log(GUTTER + accent(` saved to ${file}`));
6188
- console.log(
6189
- GUTTER +
6190
- dim(` ${doc.codingBackends.length} provider${doc.codingBackends.length === 1 ? "" : "s"}, ${models} model${models === 1 ? "" : "s"}, ${doc.keys.length} key name${doc.keys.length === 1 ? "" : "s"} — no secrets.`),
6191
- );
6210
+ // Counted by the tested rule — the default model included (it once read "0 models").
6211
+ console.log(GUTTER + dim(` ${st.transferSummary(doc)}`));
6192
6212
  console.log(GUTTER + dim(" load it on the other machine with /load-settings " + file + "\n"));
6193
6213
  }
6194
6214
 
6195
6215
  async function runLoadSettingsCommand(line) {
6196
6216
  const st = await import("./settings-transfer.mjs");
6197
- const file = settingsPathFrom(line, "/load-settings");
6217
+ const home = homedir();
6198
6218
  console.log("");
6219
+ // A NAME IN THE DEFAULT FOLDER, like /save-settings. With none given and more than one settings
6220
+ // file there, pick from them rather than silently taking gu-settings.json.
6221
+ let typed = settingsArg(line, "/load-settings");
6222
+ if (!typed) {
6223
+ const found = st.settingsFilesIn(listJsonFiles(home));
6224
+ if (found.length > 1) {
6225
+ console.log(dim(` Settings files in ${home} (${PICK_HINT}):`));
6226
+ const i = await pickFromList(found, 0);
6227
+ if (i < 0) {
6228
+ console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
6229
+ return;
6230
+ }
6231
+ typed = found[i];
6232
+ }
6233
+ }
6234
+ const file = st.settingsFilePath(typed, home);
6235
+ if (!file) {
6236
+ console.log(GUTTER + dim(" (cancelled — nothing changed)\n"));
6237
+ return;
6238
+ }
6199
6239
  let text;
6200
6240
  try {
6201
6241
  text = readFileSync(file, "utf8");
@@ -6240,16 +6280,16 @@ async function runLoadSettingsCommand(line) {
6240
6280
  console.log(GUTTER + red(" the API refused the settings — nothing was changed.\n"));
6241
6281
  return;
6242
6282
  }
6243
- console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}.`));
6283
+ console.log(GUTTER + accent(` applied: ${plan.adds} provider${plan.adds === 1 ? "" : "s"}${patch.ragEmbedUrl || patch.ragEmbedModel ? ", the embedding model" : ""}.`));
6244
6284
 
6245
6285
  // THE KEYS, AFTER the settings — each needs the row it belongs to, and the rows only exist
6246
6286
  // once the API has minted their ids.
6247
6287
  const fresh = (await fetchWorkerSettings()) ?? {};
6248
- const rows = Array.isArray(fresh.agentCodingBackends) ? fresh.agentCodingBackends : [];
6249
- const rowIdForUrl = (url) => {
6250
- const hit = rows.find((r) => String(r?.url ?? "").trim().toLowerCase() === String(url).trim().toLowerCase());
6251
- return hit?.id ?? "";
6252
- };
6288
+ const ps = await import("./coding-provider-setup.mjs");
6289
+ const rows = ps.existingBackendRows(fresh);
6290
+ // One server however it is spelled (task #245) — a raw compare missed "…/v1" and saved the key
6291
+ // tied to NO server, where it can never be used.
6292
+ const rowIdForUrl = (url) => ps.providerRowForUrl(rows, url)?.id ?? "";
6253
6293
  for (const k of plan.missingKeys) {
6254
6294
  const where = k.backendUrl ? ` for ${k.backendUrl}` : "";
6255
6295
  const typed = k.key || (await ask(` key "${k.name}"${k.tail ? ` (…${k.tail})` : ""}${where} — paste it, or Enter to skip: `)).trim();
@@ -6257,12 +6297,42 @@ async function runLoadSettingsCommand(line) {
6257
6297
  console.log(GUTTER + dim(` skipped ${k.name} — add it later with ctrl+k.`));
6258
6298
  continue;
6259
6299
  }
6260
- const id = await addCodingKey(k.name, typed, k.backendUrl ? rowIdForUrl(k.backendUrl) : "");
6300
+ const rowId = k.backendUrl ? rowIdForUrl(k.backendUrl) : "";
6301
+ const id = await addCodingKey(k.name, typed, rowId);
6261
6302
  console.log(GUTTER + (id ? accent(` added ${k.name}.`) : red(` could not add ${k.name}.`)));
6303
+ // The key that was its server's DEFAULT on the saving machine is the default here too.
6304
+ if (id && rowId && k.default) await setServerDefaultKey(rowId, id);
6262
6305
  }
6263
6306
  console.log(GUTTER + dim(" pick a coding model with /model.\n"));
6264
6307
  }
6265
6308
 
6309
+ /** The .json files directly in a folder, with enough of each to recognise a settings file. */
6310
+ function listJsonFiles(dir) {
6311
+ try {
6312
+ return readdirSync(dir)
6313
+ .filter((n) => /\.json$/i.test(n))
6314
+ .slice(0, 200)
6315
+ .map((name) => {
6316
+ try {
6317
+ const p = join(dir, name);
6318
+ const st = statSync(p);
6319
+ // The marker is near the top; 4 KB is plenty and keeps a huge JSON from being read whole.
6320
+ if (!st.isFile() || st.size > 5_000_000) return null;
6321
+ const fd = openSync(p, "r");
6322
+ const buf = Buffer.alloc(Math.min(4096, st.size));
6323
+ readSync(fd, buf, 0, buf.length, 0);
6324
+ closeSync(fd);
6325
+ return { name, text: buf.toString("utf8"), mtimeMs: st.mtimeMs };
6326
+ } catch {
6327
+ return null;
6328
+ }
6329
+ })
6330
+ .filter(Boolean);
6331
+ } catch {
6332
+ return [];
6333
+ }
6334
+ }
6335
+
6266
6336
  async function fetchWorkerSettings() {
6267
6337
  try {
6268
6338
  const r = await fetch(`${apiBase}/api/worker/settings`, {
@@ -6288,7 +6358,7 @@ async function fetchWorkerSettings() {
6288
6358
  * Only a model change rebuilds indexes — moving the SAME embedder to another server, or changing
6289
6359
  * only its key, leaves every vector valid, so those paths save without the plan wall. The worker
6290
6360
  * already speaks both dialects: gonext_agent_chat._embed() POSTs {base}/v1/embeddings first, then
6291
- * Ollama /api/embed and /api/embeddings, and sends the "embed-ollama" store key as a Bearer when
6361
+ * Ollama /api/embed and /api/embeddings, and sends the embed server's stored key (role=embed) as a Bearer when
6292
6362
  * one exists — so pointing at an OpenAI-compatible provider here needs no worker-side change.
6293
6363
  *
6294
6364
  * The decisions live in embed-model-command.mjs; this is the terminal half only.
@@ -6307,50 +6377,6 @@ async function saveEmbedSettings(fields) {
6307
6377
  return saved;
6308
6378
  }
6309
6379
 
6310
- // WHERE TO EMBED, ASKED ONCE, SHARED. The model sub-flow and the ollama-server action both need
6311
- // "which Ollama serves the embeddings"; it used to live inline, which meant a second copy could
6312
- // never exist. Enter keeps the current URL; Ctrl+C/Escape returns null and the caller just stops.
6313
- // Anything that is not an Ollama-shaped host is rejected with the reason, then asked AGAIN —
6314
- // ollama-host-snippets' why line IS the retry message.
6315
- async function pickEmbedServerUrl({ currentUrl }) {
6316
- const ohs = await import("./ollama-host-snippets.mjs");
6317
- for (;;) {
6318
- const urlIn = await ask(` Ollama server URL for embedding [${currentUrl}]: `);
6319
- if (urlIn === null) return null;
6320
- const typed = urlIn.trim();
6321
- if (!typed) return currentUrl;
6322
- const embedUrl = /^https?:\/\//i.test(typed) ? typed : `http://${typed}`;
6323
- const r = ohs.checkOllamaHostInput(embedUrl);
6324
- if (r.ok) return embedUrl;
6325
- console.log(GUTTER + dim(` "${typed}" is ${r.why} — try host:port, e.g. 127.0.0.1:11434`));
6326
- }
6327
- }
6328
-
6329
- // THE KEY, ASKED ONCE, SHARED. ollama.com cannot proceed without one; the OpenAI-compatible
6330
- // provider row asks the same way (Enter skips it — a local vLLM answers anonymously). All embed
6331
- // keys land under the fixed backendId "embed-ollama" because that is the id
6332
- // gonext_agent_chat._rag_resolve_embed_api_key() fetches, whatever the server is; ragEmbedKeyId
6333
- // is just the human label the settings panel shows. Returns { cancelled, keyId } — a cancelled
6334
- // prompt writes NOTHING, and a failed store keeps the key out of the settings so the user is not
6335
- // told they authenticated when they did not.
6336
- async function askEmbedKey(url, { optional = false } = {}) {
6337
- console.log(GUTTER + dim('A name just tells keys apart — "embed", "work".'));
6338
- const name = (await askKeyName()).trim();
6339
- console.log(GUTTER + dim(SECRET_KEY_HINT));
6340
- const key = (await askSecret(` API key for ${url}${name ? ` (${name})` : ""}: `)).trim();
6341
- if (!key) {
6342
- if (optional) console.log(GUTTER + dim("(no key — the provider will be called anonymously)"));
6343
- return { cancelled: !optional, keyId: "", key: "" };
6344
- }
6345
- const id = await withPendingStatus("saving the key", () => addCodingKey(name || "embed", key, "embed-ollama"));
6346
- if (!id) {
6347
- console.log(red(" couldn't save the embed key — searching stays unauthenticated.\n"));
6348
- return { cancelled: true, keyId: "", key: "" };
6349
- }
6350
- console.log(GUTTER + dim(`embed key "${name || id}" saved.\n`));
6351
- return { cancelled: false, keyId: id, key };
6352
- }
6353
-
6354
6380
  // THE RECOGNITION STEP, ONE WAY. Switching embedders invalidates the indexes; naming the projects
6355
6381
  // that stop working is the decision the user is actually making, so the plan is read BEFORE the
6356
6382
  // "switch / keep" picker — never after, never implied. Returns { changed:false } when the user
@@ -6438,147 +6464,33 @@ async function chooseEmbedModel() {
6438
6464
  return;
6439
6465
  }
6440
6466
 
6467
+ // EVERY ARM IS THE ONE ADD-SERVER SCREEN (task #245) — the same URL → key → models → save as
6468
+ // /model and /agent-model. The curated arm has already chosen the model (and passed the
6469
+ // recognition wall), so it only asks WHERE; the other two list what the server has.
6441
6470
  if (action === "model") {
6442
- // WHICH + WHERE, one pass: the recognition wall first (it is the expensive decision), then
6443
- // the server prompt, then — only on ollama.com — the key. Same addition coding and agent
6444
- // models already have (#127): the key is named, stored in the shared account store
6445
- // (settings.agentCodingApiKeys) tagged "embed-ollama", and its id recorded in ragEmbedKeyId so
6446
- // the embedder can name what it sends. Cancelled url/key prompts abort cleanly: the key, if
6447
- // typed, is already in the store and the settings write is simply skipped.
6448
6471
  const { changed, chosen, plan } = await pickEmbedModel(ec, current);
6449
6472
  if (!changed) return;
6450
- const embedUrl = await pickEmbedServerUrl({ currentUrl });
6451
- if (embedUrl === null) {
6452
- console.log(dim(" (cancelled)\n"));
6453
- return;
6454
- }
6455
- let embedKeyId = settings.ragEmbedKeyId ?? "";
6456
- if (isOllamaCloudUrl(embedUrl)) {
6457
- const k = await askEmbedKey(embedUrl);
6458
- if (k.cancelled) return;
6459
- embedKeyId = k.keyId;
6460
- }
6461
- // A FAILED WRITE MUST NOT LOOK LIKE A SUCCESS (#169): the next turn would keep using the old
6462
- // embedder while the user is told the index was rebuilt for a change that never happened.
6463
- if (!(await saveEmbedSettings({ ragEmbedModel: chosen, ragEmbedUrl: embedUrl, ragEmbedKeyId: embedKeyId }))) return;
6464
- announceEmbedSaved({ chosen, plan, url: embedUrl });
6473
+ await addServer({ role: "embed", dialect: "ollama", model: chosen, plan });
6465
6474
  return;
6466
6475
  }
6476
+ if (action === "ollama") return addServer({ role: "embed", dialect: "ollama" });
6477
+ if (action === "provider") return addServer({ role: "embed", dialect: "openai" });
6467
6478
 
6468
- if (action === "ollama") {
6469
- // SAME MODEL, NEW OLLAMA. Vectors come from the model, not the box — moving nomic-embed-text
6470
- // from 127.0.0.1 to another Ollama changes no numbers, so no wall and no rebuild. The
6471
- // ollama.com arm still collects the key; a keyless host clears the stale ragEmbedKeyId label.
6472
- const embedUrl = await pickEmbedServerUrl({ currentUrl });
6473
- if (embedUrl === null) {
6474
- console.log(dim(" (cancelled)\n"));
6475
- return;
6476
- }
6477
- let embedKeyId = "";
6478
- if (isOllamaCloudUrl(embedUrl)) {
6479
- const k = await askEmbedKey(embedUrl);
6480
- if (k.cancelled) return;
6481
- embedKeyId = k.keyId;
6482
- }
6483
- // Same box, no new key: keep the no-op honest instead of writing noise over the settings.
6484
- if (embedUrl === currentUrl && !embedKeyId) {
6485
- console.log(dim(` already embedding on ${host || currentUrl} — nothing changed.\n`));
6486
- return;
6487
- }
6488
- if (!(await saveEmbedSettings({ ragEmbedUrl: embedUrl, ragEmbedKeyId: embedKeyId }))) return;
6489
- console.log(accent(` ✓ embedding server → ${hostOf(embedUrl)}`));
6490
- console.log(dim(` ${current} did not change, so no knowledge base rebuilds — the same model just answers from another box.\n`));
6491
- return;
6492
- }
6493
-
6494
- if (action === "provider") {
6495
- // ANOTHER OPENAI-COMPATIBLE PROVIDER. _embed() tries {base}/v1/embeddings first, so any
6496
- // /v1-shaped server works — vLLM, OpenRouter, LM Studio, a hosted gateway. The key comes
6497
- // BEFORE the list: most providers 401 an anonymous /v1/models, and a keyless local server
6498
- // just answers, so Enter skips the key. The model id then comes from the provider's own
6499
- // /v1/models when it answers, typed EXACTLY as the provider calls it when it does not — the
6500
- // worker POSTs the string verbatim, so both are supported.
6479
+ if (action === "key") {
6480
+ // THE SAME KEY MANAGER as /model's ctrl+k and /agent-model (task #245): this server's keys
6481
+ // only — switch (this folder's embedder, or the server's default), add, delete. A server
6482
+ // with no row yet gets one here, with its first key, exactly as the model arms add it.
6501
6483
  const ps = await import("./coding-provider-setup.mjs");
6502
- let base = "";
6503
- for (;;) {
6504
- const raw = await ask(` provider base URL (e.g. https://openrouter.ai/api/v1): `);
6505
- if (raw === null || !raw.trim()) {
6506
- console.log(dim(" (cancelled)\n"));
6507
- return;
6508
- }
6509
- const typed = raw.trim();
6510
- base = /^https?:\/\//i.test(typed) ? typed : `https://${typed}`;
6511
- try {
6512
- new URL(base);
6513
- break;
6514
- } catch {
6515
- console.log(yellow(" that is not a URL — try https://host or https://host/v1"));
6516
- }
6517
- }
6518
- base = ps.ensureV1Root(base);
6519
- const providerHost = hostOf(base);
6520
- const k = await askEmbedKey(base, { optional: true });
6521
- const embedKeyId = k.keyId;
6522
- const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6523
- let chosen = "";
6524
- let listed = [];
6525
- try {
6526
- const am = await import("./agent-model-command.mjs");
6527
- const r = await withPendingStatus(`asking ${providerHost} for its models`, () =>
6528
- proxyFetch(`${base}/models`, { headers: am.agentListHeaders(k.key || ""), signal: AbortSignal.timeout(12000) })
6529
- );
6530
- if (r.ok) listed = am.agentModelsFromBody("openai", await r.json().catch(() => null));
6531
- } catch { /* an unreachable list is not a dead end — typing is fully supported, below */ }
6532
- if (listed.length) {
6533
- // Embedders first, the rest after, each once: the provider's catalog is mostly chat models,
6534
- // and burying "text-embedding-3-large" under 200 of them is a menu nobody can use.
6535
- const am = await import("./agent-model-command.mjs");
6536
- const ordered = [...listed].sort((a, b) =>
6537
- Number(am.isLikelyEmbedder(b)) - Number(am.isLikelyEmbedder(a)) || a.model.localeCompare(b.model));
6538
- const labels = ordered.map((r) =>
6539
- `${r.model}${r.note ? dim(` · ${r.note}`) : ""}` + (ec.sameEmbedModel(r.model, current) ? accent(" (current)") : ""));
6540
- const li = await pickFromList(labels, Math.max(0, ordered.findIndex((r) => ec.sameEmbedModel(r.model, current))));
6541
- if (li < 0) {
6542
- console.log(dim(" (cancelled)\n"));
6543
- return;
6544
- }
6545
- chosen = ordered[li].model;
6484
+ const found = ps.providerRowForUrl(ps.existingBackendRows(settings), currentUrl);
6485
+ if (!found) {
6486
+ // No server row yet: the add-server screen, URL and model already known, a key asked for.
6487
+ await addServer({ role: "embed", dialect: /\/v1\/?$/i.test(currentUrl) ? "openai" : "ollama", url: currentUrl, model: current, newKey: true });
6488
+ return;
6546
6489
  } else {
6547
- console.log(dim(` ${providerHost} did not list its models — type the embedding model id exactly as ${providerHost} calls it.`));
6548
- const raw = await ask(` embedding model id [${current}]: `);
6549
- if (raw === null) {
6550
- console.log(dim(" (cancelled)\n"));
6551
- return;
6552
- }
6553
- chosen = raw.trim() || current;
6490
+ await manageServerKeys({ role: "embed", backendId: found.id, host: host || currentUrl, model: current });
6554
6491
  }
6555
- // A MODEL CHANGE THROUGH THE PROVIDER ROW IS STILL A REINDEX — same wall, same wording. Same
6556
- // model = a pure server/key move, and no knowledge base needs a rebuild.
6557
- let plan = null;
6558
- if (!ec.sameEmbedModel(chosen, current)) {
6559
- const sw = await confirmEmbedSwitch(ec, current, chosen);
6560
- if (!sw.changed) return;
6561
- plan = sw.plan;
6562
- }
6563
- const fields = { ragEmbedUrl: base, ragEmbedKeyId: embedKeyId };
6564
- if (chosen) fields.ragEmbedModel = chosen;
6565
- if (!(await saveEmbedSettings(fields))) return;
6566
- if (plan) announceEmbedSaved({ chosen, plan, url: base });
6567
- else {
6568
- console.log(accent(` ✓ embedding provider → ${providerHost}`));
6569
- console.log(dim(` ${chosen} did not change, so no knowledge base rebuilds — the same model just answers from ${providerHost}.\n`));
6570
- }
6571
- return;
6572
- }
6573
-
6574
- if (action === "key") {
6575
- // A NEW KEY FOR THE SERVER ALREADY CHOSEN. The model and the URL stay; only the credential
6576
- // rotates — same three steps as everywhere else (name → secret → store under "embed-ollama").
6577
- const k = await askEmbedKey(host || currentUrl);
6578
- if (k.cancelled) return;
6579
- if (!(await saveEmbedSettings({ ragEmbedKeyId: k.keyId }))) return;
6580
- console.log(accent(` ✓ embedding key → "${k.keyId}"`));
6581
- console.log(dim(` ${current} still embeds on ${host || currentUrl} — the new key signs the next request.\n`));
6492
+ // The pre-#245 account-wide pick would shadow what was just chosen; the server decides now.
6493
+ if (String(settings.ragEmbedKeyId ?? "").trim()) await saveEmbedSettings({ ragEmbedKeyId: "" });
6582
6494
  }
6583
6495
  }
6584
6496
 
@@ -6613,83 +6525,47 @@ async function askKeyName(prompt = " key name: ") {
6613
6525
  return "";
6614
6526
  }
6615
6527
 
6616
- /** Ask for a key and store it BOUND to this provider row. Returns the secret, or "" if cancelled. */
6617
- async function askKeyForRow(row, host) {
6618
- console.log("\n" + GUTTER + dim('A name just tells keys apart — "work", "personal".'));
6619
- const name = await askKeyName();
6528
+ /**
6529
+ * ASK FOR A SERVER'S KEY — name, then the secret, then the shape check — ONE PROMPT for every
6530
+ * flow that adds a key (task #245): /model's add-provider and ctrl+k, /agent-model's Ollama cloud
6531
+ * and provider rows, /embed-model's three arms. It only ASKS; the caller saves (a new server's
6532
+ * row must exist before its key can be bound to it).
6533
+ *
6534
+ * Returns { name, secret }; secret "" = nothing typed — a cancel, or with `optional` a deliberate
6535
+ * "no key" (a local vLLM answers anonymously). `host` names the server in the prompt and decides
6536
+ * the shape check's locality: a local server accepts any string, so "abc" is not questioned there.
6537
+ */
6538
+ async function askServerKey({ host = "", nameHint = '"work", "personal"', optional = false } = {}) {
6539
+ const ps = await import("./coding-provider-setup.mjs");
6540
+ console.log("\n" + GUTTER + dim(`A name just tells keys apart — ${nameHint}.`));
6541
+ const name = (await askKeyName()).trim();
6620
6542
  console.log(GUTTER + dim(SECRET_KEY_HINT));
6621
- const secret = (await askSecret(` API key for ${host}${name ? ` (${name})` : ""}: `)).trim();
6543
+ const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
6622
6544
  if (!secret) {
6623
- console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6624
- return "";
6545
+ console.log(GUTTER + dim(optional ? "(no key — the server will be called anonymously)" : "(cancelled — nothing was saved)\n"));
6546
+ return { name, secret: "" };
6625
6547
  }
6626
- const id = await withPendingStatus("saving the key", () => addCodingKey(name, secret, String(row?.id ?? "")));
6627
- if (!id) {
6628
- console.log(red(" couldn't save the key — nothing changed.\n"));
6629
- return "";
6548
+ // DOES THAT LOOK LIKE A KEY? A shape check only — the server decides validity — but "abc" and a
6549
+ // README placeholder are obvious on sight and would otherwise die as a 401 several steps in.
6550
+ const shape = ps.describeApiKeyShape(secret, { host, locality: host ? ps.hostLocality(host) : "" });
6551
+ if (shape.level) {
6552
+ console.log(GUTTER + (shape.level === "warn" ? yellow(shape.note) : dim(shape.note)));
6553
+ if (shape.level === "warn" && !(await askYesNo("Use that key anyway?", false))) {
6554
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6555
+ return { name, secret: "" };
6556
+ }
6630
6557
  }
6631
- // The key just entered for THIS provider is the one to use (follow-up: the new ollama
6632
- // cloud key should become the chosen one) — pin it to this folder now so the next coder
6633
- // pick doesn't fall back to an older key, and the bar tail matches what will be sent.
6634
- sessionCodingKeyId = id;
6635
- await saveSessionCodingKeyId(resolve(process.cwd()));
6636
- sessionApiKeyTail = secret.slice(-4);
6637
- keyChoicePending = false;
6638
- codingKeyMissingKnown = false;
6639
- return secret;
6640
- }
6641
-
6642
- /** #40: the key was the problem — enter it, then list again. */
6643
- async function enterAgentProviderKey(row, host) {
6644
- if (!row) return;
6645
- if (!(await askKeyForRow(row, host))) return;
6646
- await chooseAgentModel();
6558
+ return { name, secret };
6647
6559
  }
6648
6560
 
6649
6561
  /**
6650
- * #40: point the agent at a provider that is ALREADY configured — its key if it has none (or a
6651
- * rejected one), its model list, and the agent settings.
6562
+ * #40: the key was the problem — enter it and list again. The same screen as adding a server
6563
+ * (addServer), with the server already known and a fresh key asked for.
6652
6564
  */
6653
- async function pointAgentAtProvider(row, host) {
6654
- const ps = await import("./coding-provider-setup.mjs");
6655
- const am = await import("./agent-model-command.mjs");
6656
- const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6657
- const url = ps.ensureV1Root(String(row.url ?? ""));
6658
- let key = (await withPendingStatus(`reading the key for ${host}`, () => fetchCodingKey(String(row.id ?? "")))).key;
6659
- if (!key) {
6660
- console.log(GUTTER + dim(`no API key is stored for ${host} yet.`));
6661
- key = await askKeyForRow(row, host);
6662
- if (!key) return;
6663
- }
6664
- const list = async (k) =>
6665
- withPendingStatus(`asking ${host} for its models`, async () => {
6666
- try {
6667
- const res = await proxyFetch(ps.modelsUrlFor(url), { headers: am.agentListHeaders(k), signal: AbortSignal.timeout(12_000) });
6668
- return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
6669
- } catch (err) {
6670
- return { err };
6671
- }
6672
- });
6673
- let probe = await list(key);
6674
- if (probe.status === 401 || probe.status === 403) {
6675
- console.log(yellow(` ${host} rejected the stored key (HTTP ${probe.status}) — enter the right one.`));
6676
- key = await askKeyForRow(row, host);
6677
- if (!key) return;
6678
- probe = await list(key);
6679
- }
6680
- let models = [];
6681
- if (probe.body) {
6682
- models = am.agentModelsFromProvider(probe.body).map((m) => m.model);
6683
- } else {
6684
- for (const l of ps.describeProbeFailure(probe).lines) console.log(GUTTER + yellow(l));
6685
- console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
6686
- }
6687
- const model = models.length ? await chooseFromModelList(models, "Which model talks?", 0) : (await ask(" model id: ")).trim();
6688
- if (!model) {
6689
- console.log(dim(" (cancelled — nothing was saved)\n"));
6690
- return;
6691
- }
6692
- await saveAgentModel(url, model, host);
6565
+ async function enterAgentProviderKey(row, host) {
6566
+ if (!row) return;
6567
+ const dialect = String(row.kind ?? "") === "ollama" ? "ollama" : "openai";
6568
+ await addServer({ role: "agent", dialect, url: String(row.url ?? host), newKey: true });
6693
6569
  }
6694
6570
 
6695
6571
  /**
@@ -6703,7 +6579,7 @@ async function chooseAgentProvider() {
6703
6579
  return;
6704
6580
  }
6705
6581
  const choices = am.agentProviderChoices(settings);
6706
- if (choices.length === 1) return addCodingProvider("agent");
6582
+ if (choices.length === 1) return addServer({ role: "agent", dialect: "openai" });
6707
6583
  console.log(dim(` Which OpenAI-compatible provider should the agent use? (${PICK_HINT})`));
6708
6584
  const idx = await pickFromList(choices.map((c) => c.label), 0);
6709
6585
  if (idx < 0) {
@@ -6711,133 +6587,9 @@ async function chooseAgentProvider() {
6711
6587
  return;
6712
6588
  }
6713
6589
  const pick = choices[idx];
6714
- if (pick.action === "add") return addCodingProvider("agent");
6715
- await pointAgentAtProvider(pick.row, hostOf(String(pick.row.url ?? "")));
6716
- }
6717
-
6718
- /**
6719
- * #41: the agent model on an OLLAMA server — this machine's, a LAN box, or the Ollama cloud.
6720
- *
6721
- * Local and LAN Ollama take no key: the agent URL and model are saved, and the API sends its
6722
- * placeholder, which Ollama ignores. The CLOUD (https://ollama.com) needs one, and the API finds an
6723
- * agent's key through the provider row whose URL matches — so a cloud server reuses the row that is
6724
- * already there (a kimi-k3 coder on ollama.com has one), or gets one, with the key bound to it.
6725
- */
6726
- async function addOllamaAgent() {
6727
- const am = await import("./agent-model-command.mjs");
6728
- const ps = await import("./coding-provider-setup.mjs");
6729
- const md = await import("./model-doctor.mjs");
6730
- const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6731
- const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6732
- if (!settings) {
6733
- console.log(red(" couldn't reach the API to read your current settings.\n"));
6734
- return;
6735
- }
6736
- const dflt = am.ollamaAgentDefault(settings);
6737
- console.log(dim(" An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."));
6738
- console.log(dim(` Enter uses ${dflt} · Esc cancels\n`));
6739
-
6740
- let target = null;
6741
- for (let attempt = 0; attempt < 3 && !target; attempt++) {
6742
- const typed = (await ask(" Ollama server: ")).trim();
6743
- const r = am.ollamaAgentUrl(typed || dflt);
6744
- if (!r.ok) {
6745
- console.log(GUTTER + dim(`"${typed}" is ${r.why} — try host:port, e.g. 127.0.0.1:11434`));
6746
- continue;
6747
- }
6748
- target = r;
6749
- }
6750
- if (!target) {
6751
- console.log(dim(" (nothing saved)\n"));
6752
- return;
6753
- }
6754
-
6755
- // THE CLOUD NEEDS A KEY, and the key has to belong to a provider row with this URL.
6756
- const existing = ps.existingBackendRows(settings);
6757
- let row = target.cloud
6758
- ? existing.find((b) => ps.ensureV1Root(String(b.url ?? "")).toLowerCase() === target.agentUrl.toLowerCase()) ?? null
6759
- : null;
6760
- let key = "";
6761
- let newRow = null;
6762
- let hasBoundKey = false;
6763
- if (target.cloud) {
6764
- // A KEY SAVED FOR THIS PROVIDER — not any key the account holds. ollama.com's model list answers
6765
- // without a key, so listing cannot catch a borrowed one; only asking can.
6766
- if (row) {
6767
- const keys = await withPendingStatus(`reading the keys for ${target.host}`, fetchCodingKeys);
6768
- hasBoundKey = Boolean(am.boundKeyFor(keys, row.id));
6769
- }
6770
- if (!hasBoundKey) {
6771
- if (!row) {
6772
- if (existing.length >= ps.MAX_BACKENDS) {
6773
- console.log(yellow(` ${ps.MAX_BACKENDS} providers is the limit — remove one in web Settings → Agent first.\n`));
6774
- return;
6775
- }
6776
- newRow = { id: ps.mintProviderId(existing.map((b) => String(b.id ?? ""))), kind: "ollama", enabled: true, url: target.agentUrl, defaultModel: "", models: [] };
6777
- }
6778
- console.log(GUTTER + dim(`${target.host} is the Ollama cloud — it needs an API key.`));
6779
- console.log(GUTTER + dim(SECRET_KEY_HINT));
6780
- key = (await askSecret(` API key for ${target.host}: `)).trim();
6781
- if (!key) {
6782
- console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6783
- return;
6784
- }
6785
- }
6786
- }
6787
-
6788
- // ITS MODELS, LIVE — with the key when it is the cloud.
6789
- const probe = await withPendingStatus(`asking ${target.host} for its models`, async () => {
6790
- try {
6791
- const res = await proxyFetch(target.tagsUrl, { headers: am.agentListHeaders(key), signal: AbortSignal.timeout(12_000) });
6792
- return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
6793
- } catch (err) {
6794
- return { err: err?.name === "TimeoutError" ? "no response in 12s" : (err?.message ?? String(err)) };
6795
- }
6796
- });
6797
- if (!probe.body) {
6798
- const why = probe.status ? `HTTP ${probe.status}` : probe.err;
6799
- console.log(red(` couldn't list the models on ${target.host} — ${why}.`));
6800
- if (probe.status === 401 || probe.status === 403) console.log(GUTTER + dim("the key was rejected — nothing was saved.\n"));
6801
- else console.log(GUTTER + dim("check the server is running and reachable (and /proxy if this machine needs one) — nothing was saved.\n"));
6802
- return;
6803
- }
6804
- const models = am.parseOllamaTags(probe.body);
6805
- if (!models.length) {
6806
- console.log(yellow(` ${target.host} answered but lists no models — pull one there first (ollama pull …).\n`));
6807
- return;
6808
- }
6809
- const rows = am.agentModelRows(models, am.currentAgentModel(settings));
6810
- console.log(dim(` ${models.length} on ${target.root} (${PICK_HINT}):`));
6811
- const idx = await pickFromList(
6812
- rows.map((r) => r.model + (r.embedder ? dim(" · embedding model, not for chat") : "") + (r.current ? accent(" (current)") : "")),
6813
- Math.max(0, rows.findIndex((r) => r.current))
6814
- );
6815
- if (idx < 0) {
6816
- console.log(dim(" (cancelled — nothing was saved)\n"));
6817
- return;
6818
- }
6819
- const model = rows[idx].model;
6820
-
6821
- // SAVE: the cloud's row and key first (the agent's key is found through them), then the agent.
6822
- if (newRow) {
6823
- const ok = await withPendingStatus("saving the Ollama cloud provider", () =>
6824
- md.saveWorkerSettings(apiBase, workerKey, { agentCodingBackends: [...existing, { ...newRow, defaultModel: model }] })
6825
- );
6826
- if (!ok) {
6827
- console.log(red(" couldn't save the provider — nothing changed.\n"));
6828
- return;
6829
- }
6830
- row = newRow;
6831
- console.log(GUTTER + dim(`${target.host} is also offered in /model now.`));
6832
- }
6833
- if (target.cloud && key && !hasBoundKey) {
6834
- const id = await withPendingStatus("saving the key", () => addCodingKey("Ollama cloud", key, String(row?.id ?? "")));
6835
- if (!id) {
6836
- console.log(red(" couldn't save the key — the agent was not changed.\n"));
6837
- return;
6838
- }
6839
- }
6840
- await saveAgentModel(target.agentUrl, model, target.host);
6590
+ if (pick.action === "add") return addServer({ role: "agent", dialect: "openai" });
6591
+ // A server already set up: the same screen, URL known — its key is reused, its models listed.
6592
+ await addServer({ role: "agent", dialect: String(pick.row.kind ?? "") === "ollama" ? "ollama" : "openai", url: String(pick.row.url ?? "") });
6841
6593
  }
6842
6594
 
6843
6595
  /**
@@ -6930,8 +6682,8 @@ async function chooseAgentModel() {
6930
6682
  );
6931
6683
  const action = go >= 0 ? options[go].action : "leave";
6932
6684
  if (action === "key") await enterAgentProviderKey(source.row, listHost);
6933
- else if (action === "ollama") await addOllamaAgent();
6934
- else if (action === "provider") await addCodingProvider("agent");
6685
+ else if (action === "ollama") await addServer({ role: "agent", dialect: "ollama" });
6686
+ else if (action === "provider") await addServer({ role: "agent", dialect: "openai" });
6935
6687
  else console.log("");
6936
6688
  return;
6937
6689
  }
@@ -6947,9 +6699,15 @@ async function chooseAgentModel() {
6947
6699
  console.log(dim(" (cancelled)\n"));
6948
6700
  return;
6949
6701
  }
6950
- if (firstAction === "ollama") return addOllamaAgent();
6702
+ if (firstAction === "ollama") return addServer({ role: "agent", dialect: "ollama" });
6951
6703
  if (firstAction === "provider") return chooseAgentProvider();
6952
- if (firstAction === "key") return enterAgentProviderKey(source.row, listHost);
6704
+ if (firstAction === "key") {
6705
+ // Task #245: one key manager for every role — this server's keys, this folder's AGENT pick.
6706
+ const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
6707
+ if (!rowId) return enterAgentProviderKey(source.row, listHost);
6708
+ await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings) });
6709
+ return;
6710
+ }
6953
6711
 
6954
6712
  const rows = am.agentModelRows(models, current);
6955
6713
  console.log(dim(` Agent model — the one that TALKS (currently ${current || "unset"})`));
@@ -7721,6 +7479,55 @@ async function fetchCodingKeys() {
7721
7479
  }
7722
7480
  }
7723
7481
 
7482
+ /**
7483
+ * ONE SERVER'S KEYS, masked (task #245) — then the keys tied to no server, which carry no
7484
+ * backendId. Each says whether it is the server's `first` key and its `default`. null = an API
7485
+ * that predates the filter; the caller falls back to fetchCodingKeys.
7486
+ */
7487
+ async function fetchServerKeys(backendId) {
7488
+ try {
7489
+ const r = await fetch(`${apiBase}/api/worker/coding-keys?backendId=${encodeURIComponent(backendId)}`, {
7490
+ headers: { "X-Worker-Key": workerKey },
7491
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7492
+ });
7493
+ if (!r.ok) return null;
7494
+ const j = await r.json().catch(() => ({}));
7495
+ return Array.isArray(j?.keys) ? j.keys : null;
7496
+ } catch {
7497
+ return null;
7498
+ }
7499
+ }
7500
+
7501
+ /** "Make this the server's default key" (task #245). Returns true when the API took it. */
7502
+ async function setServerDefaultKey(backendId, keyId) {
7503
+ try {
7504
+ const r = await fetch(`${apiBase}/api/worker/server-default-key`, {
7505
+ method: "PUT",
7506
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
7507
+ body: JSON.stringify({ backendId, keyId }),
7508
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7509
+ });
7510
+ return r.ok;
7511
+ } catch {
7512
+ return false;
7513
+ }
7514
+ }
7515
+
7516
+ /** What deleting a key would change — wasFirst / wasDefault / folders — WITHOUT deleting it. */
7517
+ async function previewDeleteKey(id) {
7518
+ try {
7519
+ const r = await fetch(`${apiBase}/api/worker/coding-keys/${encodeURIComponent(id)}?dryRun=1`, {
7520
+ method: "DELETE",
7521
+ headers: { "X-Worker-Key": workerKey },
7522
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7523
+ });
7524
+ if (!r.ok) return null;
7525
+ return await r.json().catch(() => null);
7526
+ } catch {
7527
+ return null;
7528
+ }
7529
+ }
7530
+
7724
7531
  /**
7725
7532
  * Add a named key. Returns its id, or "" on failure.
7726
7533
  *
@@ -7783,6 +7590,9 @@ async function fetchCodingChoice(folderHash) {
7783
7590
  codingModel: str(c.codingModel),
7784
7591
  codingLabel: str(c.codingLabel),
7785
7592
  codingUrl: str(c.codingUrl),
7593
+ // Task #245: this folder's own agent / embed keys ("" from an API that predates them).
7594
+ agentKeyId: str(c.agentKeyId),
7595
+ embedKeyId: str(c.embedKeyId),
7786
7596
  };
7787
7597
  }
7788
7598
 
@@ -7809,32 +7619,8 @@ function folderHashFor(cwd) {
7809
7619
  * larger promise than the question implied.
7810
7620
  */
7811
7621
  async function promptNewCodingKey(backendId = "", provider = "") {
7812
- console.log("\n" + GUTTER + dim("A name just tells them apart — \"work\", \"personal\".\n"));
7813
- const name = await askKeyName(" Name for this key: ");
7814
- console.log(GUTTER + dim(SECRET_KEY_HINT));
7815
- const secret = (await askSecret(` API key${name ? ` for ${name}` : ""}: `)).trim();
7816
- if (!secret) {
7817
- console.log(GUTTER + dim("(cancelled — nothing was added)\n"));
7818
- return "";
7819
- }
7820
- // The same shape check as the add-provider flow. The provider LABEL is its host, so the
7821
- // locality can be derived from it — which matters: a local server accepts any string, and
7822
- // warning a vLLM user that "abc" is not a key would be telling them their working setup is
7823
- // wrong. An unknown label falls through as hosted, where the checks belong.
7824
- {
7825
- const ps = await import("./coding-provider-setup.mjs");
7826
- const shape = ps.describeApiKeyShape(secret, {
7827
- host: provider,
7828
- locality: provider ? ps.hostLocality(provider) : "",
7829
- });
7830
- if (shape.level) {
7831
- console.log(GUTTER + (shape.level === "warn" ? yellow(shape.note) : dim(shape.note)));
7832
- if (shape.level === "warn" && !(await askYesNo("Use that key anyway?", false))) {
7833
- console.log(GUTTER + dim("(cancelled — nothing was added)\n"));
7834
- return "";
7835
- }
7836
- }
7837
- }
7622
+ const { name, secret } = await askServerKey({ host: provider });
7623
+ if (!secret) return "";
7838
7624
  const id = await addCodingKey(name, secret, backendId);
7839
7625
  console.log(
7840
7626
  id
@@ -7879,7 +7665,8 @@ async function replaceCodingKey() {
7879
7665
  const ps = await import("./coding-provider-setup.mjs");
7880
7666
  const keys = allKeys ? ps.keysForBackend(allKeys, ks.backendId) : null;
7881
7667
  if (keys && keys.length >= 1) {
7882
- await manageCodingKeys(ks, keys);
7668
+ // ONE SERVER'S KEYS (task #245): the same picker /agent-model and /embed-model open.
7669
+ await manageServerKeys({ role: "coding", backendId: ks.backendId, host: ks.provider || ks.backendId || "this server", model: ks.model });
7883
7670
  return;
7884
7671
  }
7885
7672
 
@@ -7900,13 +7687,10 @@ async function replaceCodingKey() {
7900
7687
  // the strength of a question that was asked about one provider.
7901
7688
  const id = await promptNewCodingKey(ks.backendId, ks.provider);
7902
7689
  if (id) {
7903
- // A key you just added for THIS coder is the one you mean to use (follow-up: "when i
7904
- // added a new ollama cloud api key, make it the chosen one"). Pinning used to be skipped
7905
- // here on the reasoning that one key is the account default anyway — but a SECOND key
7906
- // later leaves this folder un-pinned, and the bar then reads the old coder's key until
7907
- // ctrl+k is opened by hand. Pin now so the new key is chosen from the start.
7908
- sessionCodingKeyId = id;
7909
- await saveSessionCodingKeyId(resolve(process.cwd()));
7690
+ // THE SERVER'S FIRST KEY IS ITS DEFAULT (task #245) — the API stamped it — so this folder
7691
+ // FOLLOWS it rather than pinning a copy: a later "make X the default" then reaches this
7692
+ // folder too, which is what "the first choice is the default for every later project" means.
7693
+ await saveFolderKeyPick("coding", "");
7910
7694
  codingKeyMissingKnown = false;
7911
7695
  keyChoicePending = false;
7912
7696
  const kd = await fetchCodingKey(ks.backendId || "");
@@ -7943,127 +7727,196 @@ async function replaceCodingKey() {
7943
7727
  }
7944
7728
 
7945
7729
  /**
7946
- * Choose which stored key THIS FOLDER uses, or add another (task #222).
7947
- *
7948
- * The choice is per folder and is stored as an ID — two projects can run the same coder on
7949
- * different keys, which is the point. The secret never reaches this machine: the id travels
7950
- * with the turn, the API records it on the job, and the worker resolves it from there.
7730
+ * THE KEYS OF ONE SERVER — switch, add, make default, delete — ONE FLOW FOR ALL THREE ROLES
7731
+ * (task #245). /model's ctrl+k, /agent-model and /embed-model all land here, so a key behaves the
7732
+ * same whichever model you reached it from.
7733
+ *
7734
+ * · LISTS THIS SERVER'S KEYS ONLY ("keys of current url only"), then any key tied to no server
7735
+ * yet — picking one of those ties it to this server, since a key belongs to one URL.
7736
+ * · A PICK asks where it applies: just this folder's <role>, or the server's default for every
7737
+ * folder without its own pick. The first key chosen for a server is already its default.
7738
+ * · DELETE previews first (the API's dry run) and says what goes with it — above all when it is
7739
+ * the FIRST key entered for the server, the one its model list was loaded with.
7740
+ *
7741
+ * `role` is "coding" | "agent" | "embed"; `backendId` the server's row id; `host` its name.
7742
+ * Returns the id now in use for this folder's role, or "" when nothing changed.
7951
7743
  */
7952
- async function manageCodingKeys(ks, keys) {
7953
- const current = sessionCodingKeyId;
7954
- const rows = keys.map(
7955
- (k) =>
7956
- `${k.name}${dim(` ····${k.tail}`)}${k.id === current ? accent(" (this folder)") : ""}`
7744
+ async function manageServerKeys({ role = "coding", backendId, host, model = "" }) {
7745
+ const sk = await import("./server-keys.mjs");
7746
+ const { label: roleLabel } = sk.roleOf(role);
7747
+ const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
7748
+ // No server id = an API older than provider rows (#223): it cannot scope or default a key, so
7749
+ // the account's keys are listed and a pick is this folder's own.
7750
+ const keys = await withPendingStatus(`reading the keys for ${host}`, () =>
7751
+ backendId ? fetchServerKeys(backendId) : fetchCodingKeys()
7957
7752
  );
7753
+ if (!keys) {
7754
+ console.log(red(` couldn't read the keys for ${host} — is this machine's API up to date? (\`gu api restart\`)\n`));
7755
+ paintBar();
7756
+ return "";
7757
+ }
7758
+ const inUse = sk.keyInUse(keys, backendId, folderKeyId);
7759
+ const rows = keys.map((k) => {
7760
+ const tags = sk.keyTags(k, { folderKeyId, inUseId: inUse?.id ?? "" });
7761
+ return `${k.name}${dim(` ····${k.tail}`)}${tags.length ? accent(` (${tags.join(" · ")})`) : ""}`;
7762
+ });
7958
7763
  const ADD = rows.length;
7959
7764
  rows.push(dim("add a new key…"));
7960
- console.log(
7961
- "\n" + GUTTER +
7962
- (keys.length > 1
7963
- ? dim(`Which key should ${folderLabel()} use for `) + accent(ks.model) + dim("?\n")
7964
- : dim(`${folderLabel()} uses this key for `) + accent(ks.model) + dim(" — or add another.\n"))
7965
- );
7765
+ console.log("\n" + GUTTER + dim(`Keys for ${host} — ${folderLabel()}'s ${roleLabel}${model ? ` (${model})` : ""}\n`));
7966
7766
  console.log(GUTTER + dim("Enter chooses · ctrl+d deletes the highlighted key\n"));
7967
- const at = Math.max(0, keys.findIndex((k) => k.id === current));
7968
- const picked = await pickFromList(rows, at, { allowDelete: true });
7767
+ const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true });
7969
7768
  const idx = picked?.index ?? -1;
7970
7769
  if (idx < 0) {
7971
- console.log(GUTTER + dim("(cancelled — this folder's key is unchanged)\n"));
7770
+ console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
7972
7771
  paintBar();
7973
- return;
7772
+ return "";
7974
7773
  }
7975
7774
 
7976
7775
  if (picked.action === "delete") {
7977
- // The "add a new key…" row is not a key. Deleting it is meaningless, and treating the index
7978
- // as one would delete whichever key happens to sit last.
7979
7776
  if (idx === ADD) {
7980
7777
  console.log(GUTTER + dim("(that row is not a key — nothing deleted)\n"));
7981
7778
  paintBar();
7982
- return;
7779
+ return "";
7983
7780
  }
7984
- await deleteCodingKeyFlow(ks, keys, idx);
7985
- return;
7781
+ await deleteServerKeyFlow({
7782
+ role, key: keys[idx], host, folderKeyId,
7783
+ lastOfServer: keys.filter((k) => k.backendId === backendId).length === 1 && keys[idx].backendId === backendId,
7784
+ });
7785
+ return "";
7986
7786
  }
7987
7787
 
7988
7788
  if (idx === ADD) {
7989
- // BOUND TO THE PROVIDER WHOSE PICKER THIS IS (task #223), for the same reason the empty-list
7990
- // prompt binds: the question was asked about one provider, so the answer belongs to it.
7991
- const id = await promptNewCodingKey(ks.backendId, ks.provider);
7992
- // A key added here is the one you wanted to use — selecting it too saves a second trip
7993
- // through the picker, which is the whole reason someone opened it.
7994
- if (id) {
7995
- sessionCodingKeyId = id;
7996
- await saveSessionCodingKeyId(resolve(process.cwd()));
7997
- codingKeyMissingKnown = false;
7998
- keyChoicePending = false;
7789
+ const id = await promptNewCodingKey(backendId, host);
7790
+ if (!id) {
7791
+ paintBar();
7792
+ return "";
7999
7793
  }
8000
- paintBar();
8001
- return;
7794
+ // A key added here is one you mean to use. The API made it the server's default when it was
7795
+ // the server's first; otherwise the question is the same as for a picked key.
7796
+ const fresh = await fetchServerKeys(backendId);
7797
+ const added = fresh?.find((k) => k.id === id);
7798
+ if (added?.default) {
7799
+ console.log(accent(` ✓ ${added.name} is ${host}'s default key\n`));
7800
+ paintBar();
7801
+ return id;
7802
+ }
7803
+ return await applyKeyPick({ role, backendId, host, key: added ?? { id, name: "the new key", tail: "" } });
7804
+ }
7805
+
7806
+ const key = keys[idx];
7807
+ // A KEY TIED TO NO SERVER is tied to this one before it is used: it belongs to one URL now.
7808
+ if (!key.backendId && backendId) {
7809
+ const ok = await withPendingStatus(`tying ${key.name} to ${host}`, () => bindKeyToServer(key.id, backendId));
7810
+ if (!ok) {
7811
+ console.log(red(` couldn't tie ${key.name} to ${host} — nothing changed.\n`));
7812
+ paintBar();
7813
+ return "";
7814
+ }
7815
+ }
7816
+ return await applyKeyPick({ role, backendId, host, key });
7817
+ }
7818
+
7819
+ /**
7820
+ * Where a picked key applies (task #245): just this folder's role, or the server's default.
7821
+ * Picking the server's default itself in a folder that had its own pick CLEARS the pick — the
7822
+ * folder goes back to following the default, which is what choosing the default means.
7823
+ */
7824
+ async function applyKeyPick({ role, backendId, host, key }) {
7825
+ const sk = await import("./server-keys.mjs");
7826
+ const { label: roleLabel } = sk.roleOf(role);
7827
+ let scope = "folder";
7828
+ if (!backendId) {
7829
+ await saveFolderKeyPick(role, key.id);
7830
+ console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail}\n`));
7831
+ } else if (key.default) {
7832
+ await saveFolderKeyPick(role, "");
7833
+ console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail} · ${host}'s default\n`));
7834
+ } else {
7835
+ const choices = sk.scopeChoices({ host, role });
7836
+ console.log("\n" + GUTTER + dim(`Use ${key.name} for…`));
7837
+ const at = await pickFromList(choices.map((c) => c.label), 0);
7838
+ if (at < 0) {
7839
+ console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
7840
+ paintBar();
7841
+ return "";
7842
+ }
7843
+ scope = choices[at].scope;
7844
+ if (scope === "default") {
7845
+ const ok = await withPendingStatus(`making ${key.name} the default for ${host}`, () => setServerDefaultKey(backendId, key.id));
7846
+ if (!ok) {
7847
+ console.log(red(" couldn't change the default — nothing changed.\n"));
7848
+ paintBar();
7849
+ return "";
7850
+ }
7851
+ // This folder follows the default now, rather than pinning a copy of it.
7852
+ await saveFolderKeyPick(role, "");
7853
+ console.log(accent(` ✓ ${key.name} is now ${host}'s default`) + dim(` ····${key.tail} · every folder without its own pick\n`));
7854
+ } else {
7855
+ await saveFolderKeyPick(role, key.id);
7856
+ console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail} · this folder only\n`));
7857
+ }
7858
+ }
7859
+ if (role === "coding") {
7860
+ keyChoicePending = false;
7861
+ codingKeyMissingKnown = false;
7862
+ sessionApiKeyTail = key.tail || "";
8002
7863
  }
8003
- sessionCodingKeyId = keys[idx].id;
8004
- await saveSessionCodingKeyId(resolve(process.cwd()));
8005
- keyChoicePending = false; // chosen — the bar's mark comes off
8006
- codingKeyMissingKnown = false;
8007
- // The picked row already carries the correct masked tail. Do NOT refetch via
8008
- // fetchCodingKey(backendId): that returns the key the provider would DEFAULT to, not the key
8009
- // just chosen by id — and for an UNBOUND key backendId is "", so it answers the account
8010
- // default. Either way the bar kept showing the old/default key's tail no matter what you
8011
- // picked (reported: "choosing between multiple keys doesn't change the tail").
8012
- sessionApiKeyTail = keys[idx].tail || "";
8013
- console.log(
8014
- accent(` ✓ ${folderLabel()} → ${keys[idx].name}`) + dim(` ····${keys[idx].tail}\n`)
8015
- );
8016
7864
  paintBar();
7865
+ return key.id;
7866
+ }
7867
+
7868
+ /** Tie a key that belongs to no server to this one (PATCH, the binding only — never the secret). */
7869
+ async function bindKeyToServer(id, backendId) {
7870
+ try {
7871
+ const r = await fetch(`${apiBase}/api/worker/coding-keys/${encodeURIComponent(id)}`, {
7872
+ method: "PATCH",
7873
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
7874
+ body: JSON.stringify({ backendId }),
7875
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7876
+ });
7877
+ return r.ok;
7878
+ } catch {
7879
+ return false;
7880
+ }
8017
7881
  }
8018
7882
 
8019
7883
  /**
8020
- * Confirm and delete one key (task #222).
7884
+ * Confirm and delete one key (task #222; the warnings are #245's).
8021
7885
  *
8022
- * ASKED, NOT ASSUMED. The secret is not recoverable from anything the API keeps — there is no
8023
- * undo and no copy — so this is one of the few actions in the terminal that earns a yes/no. The
8024
- * question names the key AND its tail, because "delete accounting?" is not enough when two keys
8025
- * can share a name and only the last four characters tell them apart.
8026
- *
8027
- * The two consequences worth stating up front are stated up front: deleting the key this folder
8028
- * uses, and deleting the last key of all. Neither is refused — both are legitimate — but neither
8029
- * should be discovered afterwards.
7886
+ * ASKED, NOT ASSUMED — the secret is not recoverable. What goes with it is read from the API's
7887
+ * dry run and said BEFORE the question: the FIRST key entered for the server (its model list was
7888
+ * loaded with it), the server's default, this folder's pick, other folders' picks.
8030
7889
  */
8031
- async function deleteCodingKeyFlow(ks, keys, idx) {
8032
- const victim = keys[idx];
8033
- const isCurrent = victim.id === sessionCodingKeyId;
8034
- const isLast = keys.length === 1;
7890
+ async function deleteServerKeyFlow({ role, key, host, folderKeyId, lastOfServer = false }) {
7891
+ const sk = await import("./server-keys.mjs");
7892
+ const preview = await withPendingStatus("checking what uses this key", () => previewDeleteKey(key.id));
8035
7893
  console.log("");
8036
- if (isCurrent) {
8037
- console.log(GUTTER + orange(` ${folderLabel()} is using this key`) +
8038
- dim(" — it will fall back to the account default.\n"));
8039
- }
8040
- if (isLast) {
8041
- console.log(GUTTER + orange(" This is the only key stored") +
8042
- dim(` — ${ks.model} will have none, and coding falls back to your chat model.\n`));
8043
- }
8044
- const yes = await askYesNo(
8045
- `Delete the API key "${victim.name}" (····${victim.tail})? This cannot be undone.`,
8046
- false
8047
- );
7894
+ for (const line of sk.deleteWarnings(preview, {
7895
+ host,
7896
+ isFolderPick: key.id === folderKeyId,
7897
+ roleLabel: sk.roleOf(role).label,
7898
+ lastOfServer,
7899
+ })) {
7900
+ console.log(GUTTER + orange(` ${line}`));
7901
+ }
7902
+ const yes = await askYesNo(`Delete the API key "${key.name}" (····${key.tail})? This cannot be undone.`, false);
8048
7903
  if (!yes) {
8049
7904
  console.log(GUTTER + dim("(cancelled — nothing was deleted)\n"));
8050
7905
  paintBar();
8051
7906
  return;
8052
7907
  }
8053
- const out = await deleteCodingKey(victim.id);
7908
+ const out = await deleteCodingKey(key.id);
8054
7909
  if (!out.ok) {
8055
7910
  console.log(red(" couldn't delete the key — try again, or remove it in web Settings → Agent\n"));
8056
7911
  paintBar();
8057
7912
  return;
8058
7913
  }
8059
- // A folder pointing at a key that no longer exists would keep sending a dead id, and every
8060
- // turn would silently fall back. Clear the selection so the folder is honestly on the default.
8061
- if (isCurrent) {
8062
- sessionCodingKeyId = "";
8063
- await saveSessionCodingKeyId(resolve(process.cwd()));
7914
+ // A folder pointing at a deleted key would keep sending a dead id; put it back on the default.
7915
+ for (const r of ["coding", "agent", "embed"]) {
7916
+ const mine = r === "agent" ? sessionAgentKeyId : r === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
7917
+ if (mine === key.id) await saveFolderKeyPick(r, "");
8064
7918
  }
8065
- console.log(accent(` ✓ deleted ${out.name || victim.name}`) +
8066
- dim(` ····${victim.tail} · ${out.remaining} key(s) left\n`));
7919
+ console.log(accent(` ✓ deleted ${out.name || key.name}`) + dim(` ····${key.tail} · ${out.remaining} key(s) left\n`));
8067
7920
  paintBar();
8068
7921
  }
8069
7922
 
@@ -8641,6 +8494,10 @@ let sessionCodingModel = "";
8641
8494
  * a folder keeps meaning until someone picks.
8642
8495
  */
8643
8496
  let sessionCodingKeyId = "";
8497
+ // THIS FOLDER'S OWN KEY for the agent / the embedder (task #245) — per role, like the coder's.
8498
+ // "" = the server's default key, which is what a folder means until someone picks otherwise.
8499
+ let sessionAgentKeyId = "";
8500
+ let sessionEmbedKeyId = "";
8644
8501
  // Last 4 characters of the current API key, for display in the bottom bar
8645
8502
  let sessionApiKeyTail = "";
8646
8503
  // The friendly model name behind sessionCodingModel, for display only: the token footer
@@ -8936,24 +8793,17 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8936
8793
  // Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
8937
8794
  ...(selectedServer ? { deployServer: selectedServer } : {}),
8938
8795
  ...(sessionCodingModel ? { codingModelOverride: sessionCodingModel } : {}),
8939
- // THE FOLDER NAMES NO KEY. A key belongs to the PROVIDER it authenticates, not to a
8940
- // directory — "the keys are account scope, not folder scope … folder scope is only what
8941
- // model i choose to use … key belongs to model".
8942
- //
8943
- // Task #222 put a chosen key on each folder, and that is what produced the reported
8944
- // failure: switching between two OpenAI-compatible providers inside one folder changed the
8945
- // model and KEPT the key, because an unbound key is eligible everywhere. Each provider was
8946
- // then handed the other's credential and answered 401 —
8947
- // api.openai.com Incorrect API key provided: sk-0Ug2d***Vgic
8948
- // api.moonshot.ai Invalid Authentication
8796
+ // THIS FOLDER'S OWN KEY PICKS, PER ROLE (task #245) — ids, never secrets, recorded on the
8797
+ // job so two folders' concurrent turns each resolve their own.
8949
8798
  //
8950
- // Nothing is lost by dropping it. #222's real requirement was that two turns running at
8951
- // once must not read a shared mutable selection. That still holds without a key on the
8952
- // folder: `codingModelOverride` below names the (provider, model) pair for THIS turn, and
8953
- // the API records the provider on the job as `codingBackendId` (app.ts, from the resolved
8954
- // pick). codingKeyForJob then reads it back and prefers a key BOUND to that provider — so
8955
- // each concurrent turn resolves its own credential from its own provider, which is the
8956
- // rule above and needs nothing new.
8799
+ // They were dropped once (#222 → the "folder names no key" rule) because an UNBOUND key
8800
+ // picked in a folder followed the folder from provider to provider, and each provider was
8801
+ // handed the other's credential (api.openai.com and api.moonshot.ai both 401'd). That is
8802
+ // now impossible at the API: a pick counts only when it is one of the turn's SERVER'S OWN
8803
+ // keys (resolveCodingApiKey), and anything else falls back to that server's default key.
8804
+ ...(sessionCodingKeyId ? { codingKeyId: sessionCodingKeyId } : {}),
8805
+ ...(sessionAgentKeyId ? { agentKeyId: sessionAgentKeyId } : {}),
8806
+ ...(sessionEmbedKeyId ? { embedKeyId: sessionEmbedKeyId } : {}),
8957
8807
  }),
8958
8808
  });
8959
8809
  const data = res ? await res.json().catch(() => ({})) : {};
@@ -10154,7 +10004,35 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
10154
10004
  }
10155
10005
 
10156
10006
  // ---------- REPL ----------
10007
+ /**
10008
+ * LOAD EVERY LAZILY-IMPORTED MODULE NOW, so this process never reads one from disk later.
10009
+ *
10010
+ * REPORTED: "✓ gu 1.0.722 installed — Then restart gu", then /embed-model in the SAME terminal
10011
+ * crashed it with ERR_MODULE_NOT_FOUND …/ollama-host-snippets.mjs. The running process was still
10012
+ * 1.0.721; its /embed-model lazily imported ollama-host-snippets.mjs, and npm had just
10013
+ * replaced the package directory with 1.0.722, which no longer ships that file. A module that
10014
+ * still EXISTS is worse, not better: the old REPL silently runs against the new helper, two
10015
+ * versions mixed in one process.
10016
+ *
10017
+ * The ESM loader caches a module by its resolved URL, so once each one is loaded here every later
10018
+ * lazy import of that name gets THIS version back without touching the disk — whatever an in-app update
10019
+ * wrote there. The list is read from this file's own source, so a new lazy import is covered
10020
+ * without anyone remembering to add it. gu-cli.mjs is excluded: importing it RUNS the CLI.
10021
+ * Fire-and-forget: each import is ~5 ms, verified side-effect free (no output, no files).
10022
+ */
10023
+ function preloadLazyModules() {
10024
+ let text = "";
10025
+ try {
10026
+ text = readFileSync(fileURLToPath(import.meta.url), "utf8");
10027
+ } catch {
10028
+ return;
10029
+ }
10030
+ for (const name of lazyModuleNames(text)) import(`./${name}`).catch(() => {});
10031
+ }
10032
+
10033
+
10157
10034
  async function main() {
10035
+ preloadLazyModules();
10158
10036
  // The startup banner (logo + status facts) is printed AFTER the payload fetch below, once the
10159
10037
  // model/coder/RAG are known — see renderBanner (task #131). Nothing prints here so the banner
10160
10038
  // lands as one clean block.
@@ -10328,10 +10206,14 @@ async function main() {
10328
10206
  sessionCodingUrl = dbChoice.codingUrl;
10329
10207
  }
10330
10208
  if (dbChoice.codingKeyId) sessionCodingKeyId = dbChoice.codingKeyId;
10209
+ sessionAgentKeyId = dbChoice.agentKeyId;
10210
+ sessionEmbedKeyId = dbChoice.embedKeyId;
10331
10211
  // Refresh the local cache so the file matches the database.
10332
10212
  try {
10333
10213
  await updateSessionFile(resolve(process.cwd()), (raw) => {
10334
10214
  raw.codingKeyId = dbChoice.codingKeyId;
10215
+ raw.agentKeyId = dbChoice.agentKeyId;
10216
+ raw.embedKeyId = dbChoice.embedKeyId;
10335
10217
  raw.codingModel = dbChoice.codingModel;
10336
10218
  raw.codingLabel = dbChoice.codingLabel;
10337
10219
  raw.codingUrl = dbChoice.codingUrl;
@@ -10349,6 +10231,8 @@ async function main() {
10349
10231
  sessionCodingUrl = c.url;
10350
10232
  }
10351
10233
  sessionCodingKeyId = await loadSessionCodingKeyId(resolve(process.cwd())); // task #222
10234
+ sessionAgentKeyId = await loadSessionFolderKeyId(resolve(process.cwd()), "agent"); // task #245
10235
+ sessionEmbedKeyId = await loadSessionFolderKeyId(resolve(process.cwd()), "embed");
10352
10236
  }
10353
10237
  }
10354
10238
  // Fetch the API key tail after loading the session, if a key is configured