@tiens.nguyen/gu-cli 1.0.721 → 1.0.722

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
@@ -494,11 +494,11 @@ const fmtTokens = (n) => {
494
494
  */
495
495
  const PICK_HINT = "↑/↓ then Enter · Esc to cancel";
496
496
  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;
497
+ // How many EXTRA models one server offers alongside its default. Task #245 made it a rule, not a
498
+ // readability cap: a server URL offers AT MOST 3 models, default included (MAX_MODELS_PER_SERVER
499
+ // in coding-provider-setup.mjs and api/src/store/settingsTypes.ts — the API refuses a row that
500
+ // grows past it). A server saved with more before the rule keeps them: see extraModelRoom.
501
+ const MAX_ALT_CODERS = 2;
502
502
  const BULLET = "●"; // a neutral completed action (informational step)
503
503
  const CHECK = "✓"; // a step that succeeded
504
504
  const CROSS = "✗"; // a step that FAILED
@@ -4542,6 +4542,46 @@ async function saveSessionCodingKeyId(cwd) {
4542
4542
  await persistCodingChoiceToServer(cwd);
4543
4543
  }
4544
4544
 
4545
+ /** This folder's own key for a role (task #245), from the session-file cache. */
4546
+ async function loadSessionFolderKeyId(cwd, role) {
4547
+ const { roleOf } = await import("./server-keys.mjs");
4548
+ try {
4549
+ const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
4550
+ const v = raw?.[roleOf(role).field];
4551
+ return typeof v === "string" ? v.trim() : "";
4552
+ } catch {
4553
+ return "";
4554
+ }
4555
+ }
4556
+
4557
+ /**
4558
+ * THIS FOLDER'S OWN KEY FOR ONE ROLE (task #245) — the one place a folder's pick is written, for
4559
+ * all three roles. The session file is the offline cache; the database is the record, and its
4560
+ * write carries ONLY this role's field, which the API merges — so picking the agent's key can
4561
+ * never wipe the coder's. "" = back to the server's default.
4562
+ */
4563
+ async function saveFolderKeyPick(role, id) {
4564
+ const { roleOf } = await import("./server-keys.mjs");
4565
+ const field = roleOf(role).field;
4566
+ if (role === "agent") sessionAgentKeyId = id;
4567
+ else if (role === "embed") sessionEmbedKeyId = id;
4568
+ else sessionCodingKeyId = id;
4569
+ const cwd = resolve(process.cwd());
4570
+ try {
4571
+ await updateSessionFile(cwd, (raw) => {
4572
+ raw[field] = id;
4573
+ raw.updatedAt = new Date().toISOString();
4574
+ });
4575
+ } catch (err) {
4576
+ console.error(dim(`(key choice save failed: ${err.message})`));
4577
+ }
4578
+ try {
4579
+ await saveCodingChoice(folderHashFor(cwd), { [field]: id });
4580
+ } catch (err) {
4581
+ console.error(dim(`(key choice could not reach the database: ${err.message})`));
4582
+ }
4583
+ }
4584
+
4545
4585
  async function loadSessionCodingKeyId(cwd) {
4546
4586
  try {
4547
4587
  const raw = JSON.parse(await readFile(sessionFilePath(cwd), "utf8"));
@@ -5053,7 +5093,7 @@ async function reconfigureOllamaServer() {
5053
5093
  const ticks = await pickManyFromList(
5054
5094
  rest.map((m) => `${m.model}${am.formatSize(m.sizeBytes) ? dim(` ${am.formatSize(m.sizeBytes)}`) : ""}`),
5055
5095
  rest.map((m, i) => (already.has(m.model) ? i : -1)).filter((i) => i >= 0),
5056
- { max: MAX_ALT_CODERS }
5096
+ { max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m.model)).length) }
5057
5097
  );
5058
5098
  // null is Escape and cancels the command; an EMPTY list is a legitimate "offer none".
5059
5099
  if (ticks === null) {
@@ -5119,12 +5159,14 @@ async function reconfigureOllamaServer() {
5119
5159
  const keys = await fetchCodingKeys();
5120
5160
  const ps = await import("./coding-provider-setup.mjs");
5121
5161
  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)) {
5162
+ const found = ps.providerRowForUrl(ps.existingBackendRows(fresh ?? {}), u);
5163
+ if (found && !am.boundKeyFor(keys, found.id)) {
5126
5164
  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));
5165
+ // The shared key prompt (task #245); the server's first key becomes its default.
5166
+ const { name, secret } = await askServerKey({ host: hostOf(u) });
5167
+ if (secret && !(await withPendingStatus("saving the key", () => addCodingKey(name, secret, found.id)))) {
5168
+ console.log(red(" couldn't save the key — add it with ctrl+k.\n"));
5169
+ }
5128
5170
  }
5129
5171
  }
5130
5172
  }
@@ -5234,7 +5276,8 @@ async function pickExtraModels(rest, preChecked = []) {
5234
5276
  const ticks = await pickManyFromList(
5235
5277
  rest,
5236
5278
  rest.map((m, i) => (already.has(m) ? i : -1)).filter((i) => i >= 0),
5237
- { max: MAX_ALT_CODERS }
5279
+ // A server saved with more than the cap before #245 keeps them; it just cannot grow.
5280
+ { max: Math.max(MAX_ALT_CODERS, rest.filter((m) => already.has(m)).length) }
5238
5281
  );
5239
5282
  // null is Escape and cancels; an EMPTY list is a legitimate "offer none".
5240
5283
  if (ticks === null) return null;
@@ -5323,7 +5366,7 @@ async function manageCodingProviders() {
5323
5366
  const providers = ps.manageableProviders(rows);
5324
5367
  // NOTHING TO MANAGE IS NOT A MENU. Straight to the add flow, exactly as before.
5325
5368
  if (providers.length === 0) {
5326
- await addCodingProvider();
5369
+ await addServer({ role: "coding", dialect: "openai" });
5327
5370
  return;
5328
5371
  }
5329
5372
 
@@ -5340,7 +5383,7 @@ async function manageCodingProviders() {
5340
5383
  return;
5341
5384
  }
5342
5385
  if (idx === ADD) {
5343
- await addCodingProvider();
5386
+ await addServer({ role: "coding", dialect: "openai" });
5344
5387
  return;
5345
5388
  }
5346
5389
 
@@ -5539,324 +5582,292 @@ async function removeCodingProvider(ps, rows, p) {
5539
5582
  }
5540
5583
 
5541
5584
  /**
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.
5585
+ * ADD A MODEL SERVER — ONE SCREEN FOR THE CODER, THE AGENT AND THE EMBEDDER (task #245).
5586
+ *
5587
+ * Replaces three screens that asked the same things three ways (/model's add-provider,
5588
+ * /agent-model's Ollama server, /embed-model's Ollama and provider arms). Same questions, same
5589
+ * order, every role:
5590
+ *
5591
+ * 1. the server's URL — forgiving about what is pasted, and says what it used;
5592
+ * 2. its key — REUSED when the server already has one (a key belongs to the server, not the
5593
+ * model), asked only when the server needs one (an Ollama on this machine or the LAN never
5594
+ * does; a local vLLM may skip it). The FIRST key entered is what lists the models;
5595
+ * 3. its models, listed WITH that key — a real round trip, so a wrong URL or key fails HERE
5596
+ * rather than mid-turn. A refused key saves nothing;
5597
+ * 4. the model — coders filtered, embedders first, the agent sees everything (add-server.mjs);
5598
+ * 5. save the server row, then its key (the API makes a server's first key its default), then
5599
+ * the role's own setting.
5600
+ *
5601
+ * What differs per role or per server kind is decided in add-server.mjs, not here.
5602
+ *
5603
+ * role "coding" | "agent" | "embed"
5604
+ * dialect "openai" (an OpenAI-compatible /v1 server) | "ollama" (an Ollama: /api/tags)
5605
+ * url skip the URL question (a server picked from a list, or the embedder's current one)
5606
+ * model skip listing and picking (/embed-model's curated list already chose; `plan` with it)
5607
+ * newKey ask for a key even if the server has one (the stored one was refused)
5563
5608
  */
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;
5609
+ async function addServer({ role = "coding", dialect = "openai", url: presetUrl = "", model: presetModel = "", plan: presetPlan = null, newKey = false } = {}) {
5610
+ const as = await import("./add-server.mjs");
5590
5611
  const ps = await import("./coding-provider-setup.mjs");
5591
- const am = await import("./agent-model-command.mjs");
5612
+ const sk = await import("./server-keys.mjs");
5592
5613
  const md = await import("./model-doctor.mjs");
5593
5614
  const { proxyFetch } = await import("./proxy-dispatcher.mjs");
5615
+ const R = as.serverRole(role);
5594
5616
 
5595
5617
  const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
5596
5618
  if (!settings) {
5597
- console.log(red(" couldn\'t reach the API to read your current settings.\n"));
5619
+ console.log(red(" couldn't reach the API to read your current settings.\n"));
5598
5620
  return;
5599
5621
  }
5600
5622
  // 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.
5623
+ // its legacy fields — writing an array without it would make that coder vanish from /model.
5603
5624
  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
- );
5625
+ // What /model offers BEFORE, so a save that silently evicts a provider can be noticed (an API
5626
+ // older than row ids keys a backend by its KIND). Coding only — it is the list that can shrink.
5627
+ const beforeChoices = role === "coding"
5628
+ ? ((await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null))?.codingChoices ?? null)
5629
+ : null;
5641
5630
 
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);
5631
+ // --- 1. the server --------------------------------------------------------------------
5632
+ let target = null;
5633
+ if (presetUrl) {
5634
+ target = as.serverTarget(dialect, presetUrl);
5635
+ if (!target.ok) {
5636
+ console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
5672
5637
  return;
5673
5638
  }
5674
- if (clash) {
5675
- console.log(GUTTER + dim(`${r.host} is already configured here — pick its model in /model.`));
5676
- continue;
5639
+ } else {
5640
+ const dflt = as.defaultServerUrl(role, dialect, settings, existing);
5641
+ console.log(dim(dialect === "ollama"
5642
+ ? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
5643
+ : " Any OpenAI-compatible endpoint: OpenRouter, Together, vLLM, LM Studio, …"));
5644
+ // The address goes ABOVE, in full, and Enter's meaning is said out loud (46d1524e).
5645
+ console.log(dim(dflt ? ` Enter uses ${dflt} · Esc cancels\n` : " Esc cancels.\n"));
5646
+ for (let attempt = 0; attempt < 3 && !target; attempt++) {
5647
+ const typed = await ask(" server URL: ");
5648
+ if (typed === null || /^(no|n|skip|-)$/i.test(typed.trim())) break;
5649
+ const raw = typed.trim() || dflt;
5650
+ if (!raw) break;
5651
+ const t = as.serverTarget(dialect, raw);
5652
+ if (!t.ok) {
5653
+ if (dialect === "openai") for (const l of ps.describeUrlProblem(t.why, raw)) console.log(GUTTER + dim(l));
5654
+ else console.log(GUTTER + dim(`"${raw}" is ${t.why} — try host:port, e.g. 127.0.0.1:11434`));
5655
+ continue;
5656
+ }
5657
+ // SAY WHAT WE ASSUMED: a scheme we supplied or a path we stripped is a guess, and a wrong
5658
+ // guess shows up later as a timeout that reads like a dead server.
5659
+ if (t.root !== raw && t.url !== raw) console.log(GUTTER + dim(`using ${t.root}`));
5660
+ target = t;
5677
5661
  }
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
5662
  }
5686
- if (!url) {
5663
+ if (!target) {
5687
5664
  console.log(dim(" (nothing saved)\n"));
5688
5665
  return;
5689
5666
  }
5667
+ const host = target.host;
5690
5668
 
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"));
5669
+ // --- 2. the server's key --------------------------------------------------------------
5670
+ // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
5671
+ // entries identical in everything a person can see) — its keys come with it.
5672
+ const found = ps.providerRowForUrl(existing, target.url);
5673
+ // THE ROW CAP BEFORE ANY QUESTION about a new server: exceeding it is a silent 200 with no row,
5674
+ // and asking for a key and a model first would be the worst order to discover that in.
5675
+ if (!found && existing.length >= ps.MAX_BACKENDS) {
5676
+ console.log(yellow(` ${ps.MAX_BACKENDS} servers is the limit — remove one in /model → manage providers first.\n`));
5700
5677
  return;
5701
5678
  }
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
- }
5679
+ const need = as.keyRequirement(target);
5680
+ let key = "";
5681
+ let keyName = "";
5682
+ let typedKey = false;
5683
+ if (found && !newKey) {
5684
+ // A KEY BELONGS TO THE SERVER: one already saved is used, whichever model it was saved for.
5685
+ const keys = await withPendingStatus(`reading the keys for ${host}`, () => fetchServerKeys(found.id));
5686
+ const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
5687
+ const inUse = sk.keyInUse(keys ?? [], found.id, folderKeyId);
5688
+ if (inUse && inUse.backendId === found.id) {
5689
+ key = (await fetchCodingKey(found.id)).key;
5690
+ if (key) console.log(GUTTER + dim(`using the key "${inUse.name}" already saved for ${host}.`));
5691
+ }
5692
+ }
5693
+ const askKey = async () => {
5694
+ const t = await askServerKey({ host, optional: need === "optional" });
5695
+ key = t.secret;
5696
+ keyName = t.name;
5697
+ typedKey = Boolean(t.secret);
5698
+ return t.secret || need !== "required";
5699
+ };
5700
+ if (!key && (need !== "none" || newKey)) {
5701
+ if (need === "required") console.log(GUTTER + dim(`${host} needs an API key.`));
5702
+ if (!(await askKey())) return;
5715
5703
  }
5716
5704
 
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
- });
5705
+ // --- 3. its models, listed with that key ----------------------------------------------
5733
5706
  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));
5707
+ if (!presetModel) {
5708
+ const list = () =>
5709
+ withPendingStatus(`asking ${host} what it has`, async () => {
5710
+ const req = as.listRequest(target, key);
5711
+ try {
5712
+ const res = await proxyFetch(req.url, { headers: req.headers, signal: AbortSignal.timeout(12_000) });
5713
+ return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
5714
+ } catch (err) {
5715
+ return { err };
5716
+ }
5717
+ });
5718
+ let probe = await list();
5719
+ // THE STORED KEY WAS REFUSED — ask for the right one once, rather than saving nothing.
5720
+ if ((probe.status === 401 || probe.status === 403) && !typedKey && key) {
5721
+ console.log(yellow(` ${host} refused the stored key (HTTP ${probe.status}) — enter the right one.`));
5722
+ if (!(await askKey()) || !key) return;
5723
+ probe = await list();
5724
+ }
5725
+ if (probe.status || probe.err) {
5726
+ const d = ps.describeProbeFailure(probe);
5727
+ for (const l of d.lines) console.log(GUTTER + (d.fatal ? red(l) : yellow(l)));
5728
+ // FATAL = THE KEY WAS REFUSED: storing a credential the server already rejected is storing
5729
+ // a problem for later. Anything else is survivable — the model id can be typed.
5730
+ if (d.fatal) {
5731
+ console.log(GUTTER + dim("nothing was saved — check the key and try again.\n"));
5732
+ return;
5733
+ }
5734
+ console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
5735
+ } else {
5736
+ const parsed = as.modelsForRole(role, target.dialect, probe.body);
5737
+ models = parsed.models;
5738
+ for (const l of ps.describeModelProblem(parsed.why, { host, total: parsed.total })) console.log(GUTTER + dim(l));
5739
+ const rn = ps.retiredNote(parsed.retired, host);
5740
+ if (rn) console.log(GUTTER + dim(rn));
5741
+ if (models.length) console.log(GUTTER + dim(`${host} offers ${models.length} model${models.length === 1 ? "" : "s"}`));
5751
5742
  }
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));
5743
+ }
5744
+
5745
+ // --- 4. the model ---------------------------------------------------------------------
5746
+ let model = presetModel;
5747
+ const ec = role === "embed" ? await import("./embed-model-command.mjs") : null;
5748
+ if (!model) {
5756
5749
  if (models.length) {
5757
- console.log(GUTTER + dim(`${host} offers ${models.length} model${models.length === 1 ? "" : "s"}`));
5750
+ const am = await import("./agent-model-command.mjs");
5751
+ const current = role === "agent" ? am.currentAgentModel(settings) : role === "embed" ? ec.currentEmbedModel(settings) : sessionCodingLabel;
5752
+ model = R.filterCoders
5753
+ ? await chooseCodingModelFrom(ps, models, R.question, 0)
5754
+ : await chooseFromModelList(models, R.question, Math.max(0, models.indexOf(current)));
5755
+ if (model === null) {
5756
+ console.log(dim(" (cancelled — nothing was saved)\n"));
5757
+ return;
5758
+ }
5759
+ } else {
5760
+ model = String((await ask(" model id: ")) ?? "").trim();
5761
+ }
5762
+ if (!model) {
5763
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
5764
+ return;
5758
5765
  }
5759
5766
  }
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"));
5767
+ // THE EMBEDDER'S RECOGNITION WALL: a different model invalidates every index, so the projects
5768
+ // that stop working are named BEFORE anything is saved. The same model is a pure server move.
5769
+ let plan = presetPlan;
5770
+ if (role === "embed" && !presetModel) {
5771
+ const current = ec.currentEmbedModel(settings);
5772
+ if (!ec.sameEmbedModel(model, current)) {
5773
+ const sw = await confirmEmbedSwitch(ec, current, model);
5774
+ if (!sw.changed) return;
5775
+ plan = sw.plan;
5776
+ } else if (as.serverTarget(target.dialect, String(settings.ragEmbedUrl ?? "")).root === target.root && !typedKey) {
5777
+ console.log(dim(` already embedding with ${model} on ${host} — nothing changed.\n`));
5775
5778
  return;
5776
5779
  }
5777
- } else {
5778
- model = (await ask(" model id: ")).trim();
5779
5780
  }
5780
- if (!model) {
5781
- console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
5782
- return;
5781
+ // MORE THAN ONE MODEL from a NEW server, for the coder: the default is what runs, these are the
5782
+ // others /model may switch to. At most MAX_MODELS_PER_SERVER in all (the API refuses more).
5783
+ let extras = [];
5784
+ if (R.extras && !found && models.length > 1) {
5785
+ extras = await pickExtraModels(models.filter((m) => m !== model));
5786
+ if (extras === null) {
5787
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
5788
+ return;
5789
+ }
5783
5790
  }
5784
5791
 
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;
5792
+ // --- 5. save: the server, its key, then the role --------------------------------------
5793
+ let rowId = found?.id ?? "";
5794
+ let rows = existing;
5795
+ // A NEW ROW ONLY WHEN SOMETHING NEEDS ONE: the coder is picked through its row, and a key is
5796
+ // bound to one. A keyless Ollama on this machine or the LAN for the agent or the embedder needs
5797
+ // neither — the role's own setting holds its URL — and a row would put it in /model as a coder.
5798
+ const needRow = role === "coding" || typedKey;
5799
+ if (!found && needRow) {
5800
+ rowId = ps.mintProviderId(existing.map((b) => String(b.id ?? "")));
5801
+ const { row } = as.rowForSave(role, { id: rowId, kind: target.dialect === "ollama" ? "ollama" : "openai", url: target.url, model, extras });
5802
+ rows = [...existing, row];
5803
+ } else {
5804
+ const r = as.rowForSave(role, { existing: found.row, model });
5805
+ if (r.full) {
5806
+ console.log(yellow(` ${host} already offers ${ps.MAX_MODELS_PER_SERVER} models — remove one in /model → manage providers first.\n`));
5807
+ return;
5808
+ }
5809
+ if (r.changed) rows = existing.map((b) => (b === found.row ? r.row : b));
5793
5810
  }
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;
5811
+ if (rows !== existing) {
5812
+ const ok = await withPendingStatus(`saving ${host}`, () => md.saveWorkerSettings(apiBase, workerKey, { agentCodingBackends: rows }));
5813
+ if (!ok) {
5814
+ console.log(red(` couldn't save ${host} — nothing changed.`));
5815
+ console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
5816
+ return;
5817
+ }
5818
+ if (!found && role !== "coding" && as.serverRole(role).rowModel) console.log(GUTTER + dim(`${host} is also offered in /model now.`));
5805
5819
  }
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;
5820
+ if (typedKey) {
5821
+ const id = await withPendingStatus("saving the key", () => addCodingKey(keyName, key, rowId));
5822
+ if (!id) {
5823
+ console.log(yellow(` ${host} was saved, but its key was not — nothing else changed.`));
5824
+ console.log(GUTTER + dim("try again, or add the key with ctrl+k.\n"));
5825
+ return;
5826
+ }
5827
+ // A key typed for a server that already had keys was typed because there was none usable or
5828
+ // the stored one was refused: it is the server's key from now on, for every role and folder.
5829
+ if (found) await setServerDefaultKey(rowId, id);
5815
5830
  }
5831
+ const keyLine = key ? dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key`) : "";
5816
5832
 
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.
5833
+ if (role === "agent") {
5834
+ // A MACHINE SETTING, not a folder pick: the agent is what gu talks to in every folder. Saved
5835
+ // as URL + NAME, the pair app.ts matches back to the server row to find the key.
5836
+ if (await saveAgentModel(target.roleUrl("agent"), model, host)) {
5837
+ if (keyLine) console.log(keyLine + "\n");
5838
+ console.log(GUTTER + dim("the banner shows the new model next time gu starts.\n"));
5839
+ } else if (rows !== existing || typedKey) {
5840
+ // The SERVER landed — say so, or the user re-enters a key that is already stored.
5847
5841
  console.log(yellow(` ${host} was saved, but the agent model was not pointed at it.`));
5848
5842
  console.log(GUTTER + dim("run /agent-model to finish, or check the API is reachable.\n"));
5849
- return;
5850
5843
  }
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
5844
  return;
5858
5845
  }
5846
+ if (role === "embed") {
5847
+ // ragEmbedKeyId "" — the server's own keys decide now (its default, or this folder's pick).
5848
+ const fields = { ragEmbedUrl: target.roleUrl("embed"), ragEmbedModel: model, ragEmbedKeyId: "" };
5849
+ if (!(await saveEmbedSettings(fields))) return;
5850
+ if (plan) announceEmbedSaved({ chosen: model, plan, url: target.roleUrl("embed") });
5851
+ else {
5852
+ console.log(accent(` ✓ embedding server → ${host}`));
5853
+ console.log(dim(` ${model} did not change, so no knowledge base rebuilds — the same model just answers from ${host}.\n`));
5854
+ }
5855
+ return;
5856
+ }
5857
+ await useCoderInThisFolder({ ps, rows, beforeChoices, url: target.url, host, locality: target.locality, model, backendId: rowId, key, keyName });
5858
+ }
5859
5859
 
5860
+ /**
5861
+ * The coder just added becomes THIS FOLDER's pick (not the account default — per-folder is
5862
+ * reversible and nobody asked for account-wide). Split out of addServer because only the coder
5863
+ * has it, and it carries the two checks that were learned the hard way:
5864
+ *
5865
+ * · ASK THE API WHAT THE PICK IS CALLED. The wire id belongs to the API (a bare model or
5866
+ * `${rowId}::${model}`); guessing it here once stored a pick the next turn rejected.
5867
+ * · DID SAVING EVICT ANOTHER PROVIDER? An API older than row ids keys a backend by its KIND,
5868
+ * so a second openai row replaced the first with a 200. Said BEFORE the success line.
5869
+ */
5870
+ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locality, model, backendId, key, keyName }) {
5860
5871
  const cwd = resolve(process.cwd());
5861
5872
  const after = await withPendingStatus("checking it is offered", () =>
5862
5873
  fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null)
@@ -5866,28 +5877,13 @@ async function addCodingProvider(roleName = "coding") {
5866
5877
  (c) => c.model === model && (c.backendId ? c.backendId === backendId : sameHost(c.url, url))
5867
5878
  );
5868
5879
  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
5880
  console.log(yellow(` ${host} was saved, but this API is not offering ${model} yet.`));
5872
5881
  console.log(GUTTER + dim("run /model — if it is missing there too, the API may be older than"));
5873
5882
  console.log(GUTTER + dim("this feature; restart it with `gu api restart`.\n"));
5874
5883
  return;
5875
5884
  }
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.
5885
+ // Two questions, and they catch different failures: a row WE SENT failed to land, or something
5886
+ // that used to be offered has gone (including a provider this client never saw).
5891
5887
  const lost = Array.from(
5892
5888
  new Set([
5893
5889
  ...ps.providerUrlsLost(rows, after?.codingChoices),
@@ -5898,32 +5894,27 @@ async function addCodingProvider(roleName = "coding") {
5898
5894
  console.log(yellow(` ⚠ this API dropped ${lost.length === 1 ? "a provider" : `${lost.length} providers`} when saving:`));
5899
5895
  for (const u of lost) console.log(GUTTER + dim(` ${u}`));
5900
5896
  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
5897
  console.log(GUTTER + dim("this API identifies a backend by its KIND, so it holds ONE"));
5904
5898
  console.log(GUTTER + dim("OpenAI-compatible provider at a time. A newer one keeps several."));
5905
5899
  }
5906
5900
  console.log(GUTTER + dim("restart it with `gu api restart`, then add that provider again.\n"));
5907
5901
  }
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
5902
  sessionCodingModel = offered.id;
5912
5903
  sessionCodingLabel = model;
5913
5904
  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
5905
+ // The folder follows the SERVER's key (task #245) — its default, which a first key already is.
5906
+ sessionCodingKeyId = "";
5907
+ sessionApiKeyTail = key ? key.slice(-4) : "";
5908
+ keyChoicePending = false;
5917
5909
  await saveSessionCoding(cwd);
5918
5910
  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.
5911
+ // The key question for this coder is answered — leaving the flag set would warn about a key
5912
+ // that was just entered, which reads as the save having failed.
5921
5913
  codingKeyMissingKnown = false;
5922
5914
  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.
5915
+ if (key) console.log(dim(` API key ${keyName || "saved"} ····${key.slice(-4)} · ${host}'s key\n`));
5916
+ // A HOSTED SERVER NEEDS ITS WINDOW SET: with nothing set gu assumes 24,000 prompt tokens and
5917
+ // compacts there — on a large hosted model that throws history away every turn.
5927
5918
  if (ps.shouldMentionContextWindow(locality)) {
5928
5919
  console.log(GUTTER + dim("tip: /context-window — hosted servers do not report their size,"));
5929
5920
  console.log(GUTTER + dim(" so gu assumes 24,000 prompt tokens and compacts there.\n"));
@@ -6288,7 +6279,7 @@ async function fetchWorkerSettings() {
6288
6279
  * Only a model change rebuilds indexes — moving the SAME embedder to another server, or changing
6289
6280
  * only its key, leaves every vector valid, so those paths save without the plan wall. The worker
6290
6281
  * 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
6282
+ * Ollama /api/embed and /api/embeddings, and sends the embed server's stored key (role=embed) as a Bearer when
6292
6283
  * one exists — so pointing at an OpenAI-compatible provider here needs no worker-side change.
6293
6284
  *
6294
6285
  * The decisions live in embed-model-command.mjs; this is the terminal half only.
@@ -6307,50 +6298,6 @@ async function saveEmbedSettings(fields) {
6307
6298
  return saved;
6308
6299
  }
6309
6300
 
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
6301
  // THE RECOGNITION STEP, ONE WAY. Switching embedders invalidates the indexes; naming the projects
6355
6302
  // that stop working is the decision the user is actually making, so the plan is read BEFORE the
6356
6303
  // "switch / keep" picker — never after, never implied. Returns { changed:false } when the user
@@ -6438,147 +6385,33 @@ async function chooseEmbedModel() {
6438
6385
  return;
6439
6386
  }
6440
6387
 
6388
+ // EVERY ARM IS THE ONE ADD-SERVER SCREEN (task #245) — the same URL → key → models → save as
6389
+ // /model and /agent-model. The curated arm has already chosen the model (and passed the
6390
+ // recognition wall), so it only asks WHERE; the other two list what the server has.
6441
6391
  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
6392
  const { changed, chosen, plan } = await pickEmbedModel(ec, current);
6449
6393
  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 });
6394
+ await addServer({ role: "embed", dialect: "ollama", model: chosen, plan });
6465
6395
  return;
6466
6396
  }
6397
+ if (action === "ollama") return addServer({ role: "embed", dialect: "ollama" });
6398
+ if (action === "provider") return addServer({ role: "embed", dialect: "openai" });
6467
6399
 
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.
6400
+ if (action === "key") {
6401
+ // THE SAME KEY MANAGER as /model's ctrl+k and /agent-model (task #245): this server's keys
6402
+ // only — switch (this folder's embedder, or the server's default), add, delete. A server
6403
+ // with no row yet gets one here, with its first key, exactly as the model arms add it.
6501
6404
  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;
6405
+ const found = ps.providerRowForUrl(ps.existingBackendRows(settings), currentUrl);
6406
+ if (!found) {
6407
+ // No server row yet: the add-server screen, URL and model already known, a key asked for.
6408
+ await addServer({ role: "embed", dialect: /\/v1\/?$/i.test(currentUrl) ? "openai" : "ollama", url: currentUrl, model: current, newKey: true });
6409
+ return;
6546
6410
  } 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;
6554
- }
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;
6411
+ await manageServerKeys({ role: "embed", backendId: found.id, host: host || currentUrl, model: current });
6562
6412
  }
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`));
6413
+ // The pre-#245 account-wide pick would shadow what was just chosen; the server decides now.
6414
+ if (String(settings.ragEmbedKeyId ?? "").trim()) await saveEmbedSettings({ ragEmbedKeyId: "" });
6582
6415
  }
6583
6416
  }
6584
6417
 
@@ -6613,83 +6446,47 @@ async function askKeyName(prompt = " key name: ") {
6613
6446
  return "";
6614
6447
  }
6615
6448
 
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();
6449
+ /**
6450
+ * ASK FOR A SERVER'S KEY — name, then the secret, then the shape check — ONE PROMPT for every
6451
+ * flow that adds a key (task #245): /model's add-provider and ctrl+k, /agent-model's Ollama cloud
6452
+ * and provider rows, /embed-model's three arms. It only ASKS; the caller saves (a new server's
6453
+ * row must exist before its key can be bound to it).
6454
+ *
6455
+ * Returns { name, secret }; secret "" = nothing typed — a cancel, or with `optional` a deliberate
6456
+ * "no key" (a local vLLM answers anonymously). `host` names the server in the prompt and decides
6457
+ * the shape check's locality: a local server accepts any string, so "abc" is not questioned there.
6458
+ */
6459
+ async function askServerKey({ host = "", nameHint = '"work", "personal"', optional = false } = {}) {
6460
+ const ps = await import("./coding-provider-setup.mjs");
6461
+ console.log("\n" + GUTTER + dim(`A name just tells keys apart — ${nameHint}.`));
6462
+ const name = (await askKeyName()).trim();
6620
6463
  console.log(GUTTER + dim(SECRET_KEY_HINT));
6621
- const secret = (await askSecret(` API key for ${host}${name ? ` (${name})` : ""}: `)).trim();
6464
+ const secret = (await askSecret(` API key${host ? ` for ${host}` : ""}${name ? ` (${name})` : ""}: `)).trim();
6622
6465
  if (!secret) {
6623
- console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6624
- return "";
6466
+ console.log(GUTTER + dim(optional ? "(no key — the server will be called anonymously)" : "(cancelled — nothing was saved)\n"));
6467
+ return { name, secret: "" };
6625
6468
  }
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 "";
6469
+ // DOES THAT LOOK LIKE A KEY? A shape check only — the server decides validity — but "abc" and a
6470
+ // README placeholder are obvious on sight and would otherwise die as a 401 several steps in.
6471
+ const shape = ps.describeApiKeyShape(secret, { host, locality: host ? ps.hostLocality(host) : "" });
6472
+ if (shape.level) {
6473
+ console.log(GUTTER + (shape.level === "warn" ? yellow(shape.note) : dim(shape.note)));
6474
+ if (shape.level === "warn" && !(await askYesNo("Use that key anyway?", false))) {
6475
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6476
+ return { name, secret: "" };
6477
+ }
6630
6478
  }
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();
6479
+ return { name, secret };
6647
6480
  }
6648
6481
 
6649
6482
  /**
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.
6483
+ * #40: the key was the problem — enter it and list again. The same screen as adding a server
6484
+ * (addServer), with the server already known and a fresh key asked for.
6652
6485
  */
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);
6486
+ async function enterAgentProviderKey(row, host) {
6487
+ if (!row) return;
6488
+ const dialect = String(row.kind ?? "") === "ollama" ? "ollama" : "openai";
6489
+ await addServer({ role: "agent", dialect, url: String(row.url ?? host), newKey: true });
6693
6490
  }
6694
6491
 
6695
6492
  /**
@@ -6703,7 +6500,7 @@ async function chooseAgentProvider() {
6703
6500
  return;
6704
6501
  }
6705
6502
  const choices = am.agentProviderChoices(settings);
6706
- if (choices.length === 1) return addCodingProvider("agent");
6503
+ if (choices.length === 1) return addServer({ role: "agent", dialect: "openai" });
6707
6504
  console.log(dim(` Which OpenAI-compatible provider should the agent use? (${PICK_HINT})`));
6708
6505
  const idx = await pickFromList(choices.map((c) => c.label), 0);
6709
6506
  if (idx < 0) {
@@ -6711,133 +6508,9 @@ async function chooseAgentProvider() {
6711
6508
  return;
6712
6509
  }
6713
6510
  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);
6511
+ if (pick.action === "add") return addServer({ role: "agent", dialect: "openai" });
6512
+ // A server already set up: the same screen, URL known — its key is reused, its models listed.
6513
+ await addServer({ role: "agent", dialect: String(pick.row.kind ?? "") === "ollama" ? "ollama" : "openai", url: String(pick.row.url ?? "") });
6841
6514
  }
6842
6515
 
6843
6516
  /**
@@ -6930,8 +6603,8 @@ async function chooseAgentModel() {
6930
6603
  );
6931
6604
  const action = go >= 0 ? options[go].action : "leave";
6932
6605
  if (action === "key") await enterAgentProviderKey(source.row, listHost);
6933
- else if (action === "ollama") await addOllamaAgent();
6934
- else if (action === "provider") await addCodingProvider("agent");
6606
+ else if (action === "ollama") await addServer({ role: "agent", dialect: "ollama" });
6607
+ else if (action === "provider") await addServer({ role: "agent", dialect: "openai" });
6935
6608
  else console.log("");
6936
6609
  return;
6937
6610
  }
@@ -6947,9 +6620,15 @@ async function chooseAgentModel() {
6947
6620
  console.log(dim(" (cancelled)\n"));
6948
6621
  return;
6949
6622
  }
6950
- if (firstAction === "ollama") return addOllamaAgent();
6623
+ if (firstAction === "ollama") return addServer({ role: "agent", dialect: "ollama" });
6951
6624
  if (firstAction === "provider") return chooseAgentProvider();
6952
- if (firstAction === "key") return enterAgentProviderKey(source.row, listHost);
6625
+ if (firstAction === "key") {
6626
+ // Task #245: one key manager for every role — this server's keys, this folder's AGENT pick.
6627
+ const rowId = String(source.row?.id ?? "").trim() || String(source.row?.kind ?? "").trim();
6628
+ if (!rowId) return enterAgentProviderKey(source.row, listHost);
6629
+ await manageServerKeys({ role: "agent", backendId: rowId, host: listHost, model: am.currentAgentModel(settings) });
6630
+ return;
6631
+ }
6953
6632
 
6954
6633
  const rows = am.agentModelRows(models, current);
6955
6634
  console.log(dim(` Agent model — the one that TALKS (currently ${current || "unset"})`));
@@ -7721,6 +7400,55 @@ async function fetchCodingKeys() {
7721
7400
  }
7722
7401
  }
7723
7402
 
7403
+ /**
7404
+ * ONE SERVER'S KEYS, masked (task #245) — then the keys tied to no server, which carry no
7405
+ * backendId. Each says whether it is the server's `first` key and its `default`. null = an API
7406
+ * that predates the filter; the caller falls back to fetchCodingKeys.
7407
+ */
7408
+ async function fetchServerKeys(backendId) {
7409
+ try {
7410
+ const r = await fetch(`${apiBase}/api/worker/coding-keys?backendId=${encodeURIComponent(backendId)}`, {
7411
+ headers: { "X-Worker-Key": workerKey },
7412
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7413
+ });
7414
+ if (!r.ok) return null;
7415
+ const j = await r.json().catch(() => ({}));
7416
+ return Array.isArray(j?.keys) ? j.keys : null;
7417
+ } catch {
7418
+ return null;
7419
+ }
7420
+ }
7421
+
7422
+ /** "Make this the server's default key" (task #245). Returns true when the API took it. */
7423
+ async function setServerDefaultKey(backendId, keyId) {
7424
+ try {
7425
+ const r = await fetch(`${apiBase}/api/worker/server-default-key`, {
7426
+ method: "PUT",
7427
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
7428
+ body: JSON.stringify({ backendId, keyId }),
7429
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7430
+ });
7431
+ return r.ok;
7432
+ } catch {
7433
+ return false;
7434
+ }
7435
+ }
7436
+
7437
+ /** What deleting a key would change — wasFirst / wasDefault / folders — WITHOUT deleting it. */
7438
+ async function previewDeleteKey(id) {
7439
+ try {
7440
+ const r = await fetch(`${apiBase}/api/worker/coding-keys/${encodeURIComponent(id)}?dryRun=1`, {
7441
+ method: "DELETE",
7442
+ headers: { "X-Worker-Key": workerKey },
7443
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7444
+ });
7445
+ if (!r.ok) return null;
7446
+ return await r.json().catch(() => null);
7447
+ } catch {
7448
+ return null;
7449
+ }
7450
+ }
7451
+
7724
7452
  /**
7725
7453
  * Add a named key. Returns its id, or "" on failure.
7726
7454
  *
@@ -7783,6 +7511,9 @@ async function fetchCodingChoice(folderHash) {
7783
7511
  codingModel: str(c.codingModel),
7784
7512
  codingLabel: str(c.codingLabel),
7785
7513
  codingUrl: str(c.codingUrl),
7514
+ // Task #245: this folder's own agent / embed keys ("" from an API that predates them).
7515
+ agentKeyId: str(c.agentKeyId),
7516
+ embedKeyId: str(c.embedKeyId),
7786
7517
  };
7787
7518
  }
7788
7519
 
@@ -7809,32 +7540,8 @@ function folderHashFor(cwd) {
7809
7540
  * larger promise than the question implied.
7810
7541
  */
7811
7542
  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
- }
7543
+ const { name, secret } = await askServerKey({ host: provider });
7544
+ if (!secret) return "";
7838
7545
  const id = await addCodingKey(name, secret, backendId);
7839
7546
  console.log(
7840
7547
  id
@@ -7879,7 +7586,8 @@ async function replaceCodingKey() {
7879
7586
  const ps = await import("./coding-provider-setup.mjs");
7880
7587
  const keys = allKeys ? ps.keysForBackend(allKeys, ks.backendId) : null;
7881
7588
  if (keys && keys.length >= 1) {
7882
- await manageCodingKeys(ks, keys);
7589
+ // ONE SERVER'S KEYS (task #245): the same picker /agent-model and /embed-model open.
7590
+ await manageServerKeys({ role: "coding", backendId: ks.backendId, host: ks.provider || ks.backendId || "this server", model: ks.model });
7883
7591
  return;
7884
7592
  }
7885
7593
 
@@ -7900,13 +7608,10 @@ async function replaceCodingKey() {
7900
7608
  // the strength of a question that was asked about one provider.
7901
7609
  const id = await promptNewCodingKey(ks.backendId, ks.provider);
7902
7610
  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()));
7611
+ // THE SERVER'S FIRST KEY IS ITS DEFAULT (task #245) — the API stamped it — so this folder
7612
+ // FOLLOWS it rather than pinning a copy: a later "make X the default" then reaches this
7613
+ // folder too, which is what "the first choice is the default for every later project" means.
7614
+ await saveFolderKeyPick("coding", "");
7910
7615
  codingKeyMissingKnown = false;
7911
7616
  keyChoicePending = false;
7912
7617
  const kd = await fetchCodingKey(ks.backendId || "");
@@ -7943,127 +7648,196 @@ async function replaceCodingKey() {
7943
7648
  }
7944
7649
 
7945
7650
  /**
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.
7651
+ * THE KEYS OF ONE SERVER — switch, add, make default, delete — ONE FLOW FOR ALL THREE ROLES
7652
+ * (task #245). /model's ctrl+k, /agent-model and /embed-model all land here, so a key behaves the
7653
+ * same whichever model you reached it from.
7654
+ *
7655
+ * · LISTS THIS SERVER'S KEYS ONLY ("keys of current url only"), then any key tied to no server
7656
+ * yet — picking one of those ties it to this server, since a key belongs to one URL.
7657
+ * · A PICK asks where it applies: just this folder's <role>, or the server's default for every
7658
+ * folder without its own pick. The first key chosen for a server is already its default.
7659
+ * · DELETE previews first (the API's dry run) and says what goes with it — above all when it is
7660
+ * the FIRST key entered for the server, the one its model list was loaded with.
7661
+ *
7662
+ * `role` is "coding" | "agent" | "embed"; `backendId` the server's row id; `host` its name.
7663
+ * Returns the id now in use for this folder's role, or "" when nothing changed.
7951
7664
  */
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)") : ""}`
7665
+ async function manageServerKeys({ role = "coding", backendId, host, model = "" }) {
7666
+ const sk = await import("./server-keys.mjs");
7667
+ const { label: roleLabel } = sk.roleOf(role);
7668
+ const folderKeyId = role === "agent" ? sessionAgentKeyId : role === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
7669
+ // No server id = an API older than provider rows (#223): it cannot scope or default a key, so
7670
+ // the account's keys are listed and a pick is this folder's own.
7671
+ const keys = await withPendingStatus(`reading the keys for ${host}`, () =>
7672
+ backendId ? fetchServerKeys(backendId) : fetchCodingKeys()
7957
7673
  );
7674
+ if (!keys) {
7675
+ console.log(red(` couldn't read the keys for ${host} — is this machine's API up to date? (\`gu api restart\`)\n`));
7676
+ paintBar();
7677
+ return "";
7678
+ }
7679
+ const inUse = sk.keyInUse(keys, backendId, folderKeyId);
7680
+ const rows = keys.map((k) => {
7681
+ const tags = sk.keyTags(k, { folderKeyId, inUseId: inUse?.id ?? "" });
7682
+ return `${k.name}${dim(` ····${k.tail}`)}${tags.length ? accent(` (${tags.join(" · ")})`) : ""}`;
7683
+ });
7958
7684
  const ADD = rows.length;
7959
7685
  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
- );
7686
+ console.log("\n" + GUTTER + dim(`Keys for ${host} — ${folderLabel()}'s ${roleLabel}${model ? ` (${model})` : ""}\n`));
7966
7687
  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 });
7688
+ const picked = await pickFromList(rows, Math.max(0, keys.findIndex((k) => k.id === inUse?.id)), { allowDelete: true });
7969
7689
  const idx = picked?.index ?? -1;
7970
7690
  if (idx < 0) {
7971
- console.log(GUTTER + dim("(cancelled — this folder's key is unchanged)\n"));
7691
+ console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
7972
7692
  paintBar();
7973
- return;
7693
+ return "";
7974
7694
  }
7975
7695
 
7976
7696
  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
7697
  if (idx === ADD) {
7980
7698
  console.log(GUTTER + dim("(that row is not a key — nothing deleted)\n"));
7981
7699
  paintBar();
7982
- return;
7700
+ return "";
7983
7701
  }
7984
- await deleteCodingKeyFlow(ks, keys, idx);
7985
- return;
7702
+ await deleteServerKeyFlow({
7703
+ role, key: keys[idx], host, folderKeyId,
7704
+ lastOfServer: keys.filter((k) => k.backendId === backendId).length === 1 && keys[idx].backendId === backendId,
7705
+ });
7706
+ return "";
7986
7707
  }
7987
7708
 
7988
7709
  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;
7710
+ const id = await promptNewCodingKey(backendId, host);
7711
+ if (!id) {
7712
+ paintBar();
7713
+ return "";
7999
7714
  }
8000
- paintBar();
8001
- return;
7715
+ // A key added here is one you mean to use. The API made it the server's default when it was
7716
+ // the server's first; otherwise the question is the same as for a picked key.
7717
+ const fresh = await fetchServerKeys(backendId);
7718
+ const added = fresh?.find((k) => k.id === id);
7719
+ if (added?.default) {
7720
+ console.log(accent(` ✓ ${added.name} is ${host}'s default key\n`));
7721
+ paintBar();
7722
+ return id;
7723
+ }
7724
+ return await applyKeyPick({ role, backendId, host, key: added ?? { id, name: "the new key", tail: "" } });
7725
+ }
7726
+
7727
+ const key = keys[idx];
7728
+ // A KEY TIED TO NO SERVER is tied to this one before it is used: it belongs to one URL now.
7729
+ if (!key.backendId && backendId) {
7730
+ const ok = await withPendingStatus(`tying ${key.name} to ${host}`, () => bindKeyToServer(key.id, backendId));
7731
+ if (!ok) {
7732
+ console.log(red(` couldn't tie ${key.name} to ${host} — nothing changed.\n`));
7733
+ paintBar();
7734
+ return "";
7735
+ }
7736
+ }
7737
+ return await applyKeyPick({ role, backendId, host, key });
7738
+ }
7739
+
7740
+ /**
7741
+ * Where a picked key applies (task #245): just this folder's role, or the server's default.
7742
+ * Picking the server's default itself in a folder that had its own pick CLEARS the pick — the
7743
+ * folder goes back to following the default, which is what choosing the default means.
7744
+ */
7745
+ async function applyKeyPick({ role, backendId, host, key }) {
7746
+ const sk = await import("./server-keys.mjs");
7747
+ const { label: roleLabel } = sk.roleOf(role);
7748
+ let scope = "folder";
7749
+ if (!backendId) {
7750
+ await saveFolderKeyPick(role, key.id);
7751
+ console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail}\n`));
7752
+ } else if (key.default) {
7753
+ await saveFolderKeyPick(role, "");
7754
+ console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail} · ${host}'s default\n`));
7755
+ } else {
7756
+ const choices = sk.scopeChoices({ host, role });
7757
+ console.log("\n" + GUTTER + dim(`Use ${key.name} for…`));
7758
+ const at = await pickFromList(choices.map((c) => c.label), 0);
7759
+ if (at < 0) {
7760
+ console.log(GUTTER + dim("(cancelled — nothing changed)\n"));
7761
+ paintBar();
7762
+ return "";
7763
+ }
7764
+ scope = choices[at].scope;
7765
+ if (scope === "default") {
7766
+ const ok = await withPendingStatus(`making ${key.name} the default for ${host}`, () => setServerDefaultKey(backendId, key.id));
7767
+ if (!ok) {
7768
+ console.log(red(" couldn't change the default — nothing changed.\n"));
7769
+ paintBar();
7770
+ return "";
7771
+ }
7772
+ // This folder follows the default now, rather than pinning a copy of it.
7773
+ await saveFolderKeyPick(role, "");
7774
+ console.log(accent(` ✓ ${key.name} is now ${host}'s default`) + dim(` ····${key.tail} · every folder without its own pick\n`));
7775
+ } else {
7776
+ await saveFolderKeyPick(role, key.id);
7777
+ console.log(accent(` ✓ ${folderLabel()}'s ${roleLabel} → ${key.name}`) + dim(` ····${key.tail} · this folder only\n`));
7778
+ }
7779
+ }
7780
+ if (role === "coding") {
7781
+ keyChoicePending = false;
7782
+ codingKeyMissingKnown = false;
7783
+ sessionApiKeyTail = key.tail || "";
8002
7784
  }
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
7785
  paintBar();
7786
+ return key.id;
7787
+ }
7788
+
7789
+ /** Tie a key that belongs to no server to this one (PATCH, the binding only — never the secret). */
7790
+ async function bindKeyToServer(id, backendId) {
7791
+ try {
7792
+ const r = await fetch(`${apiBase}/api/worker/coding-keys/${encodeURIComponent(id)}`, {
7793
+ method: "PATCH",
7794
+ headers: { "Content-Type": "application/json", "X-Worker-Key": workerKey },
7795
+ body: JSON.stringify({ backendId }),
7796
+ signal: AbortSignal.timeout(API_CALL_TIMEOUT_MS),
7797
+ });
7798
+ return r.ok;
7799
+ } catch {
7800
+ return false;
7801
+ }
8017
7802
  }
8018
7803
 
8019
7804
  /**
8020
- * Confirm and delete one key (task #222).
8021
- *
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.
7805
+ * Confirm and delete one key (task #222; the warnings are #245's).
8026
7806
  *
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.
7807
+ * ASKED, NOT ASSUMED — the secret is not recoverable. What goes with it is read from the API's
7808
+ * dry run and said BEFORE the question: the FIRST key entered for the server (its model list was
7809
+ * loaded with it), the server's default, this folder's pick, other folders' picks.
8030
7810
  */
8031
- async function deleteCodingKeyFlow(ks, keys, idx) {
8032
- const victim = keys[idx];
8033
- const isCurrent = victim.id === sessionCodingKeyId;
8034
- const isLast = keys.length === 1;
7811
+ async function deleteServerKeyFlow({ role, key, host, folderKeyId, lastOfServer = false }) {
7812
+ const sk = await import("./server-keys.mjs");
7813
+ const preview = await withPendingStatus("checking what uses this key", () => previewDeleteKey(key.id));
8035
7814
  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
- );
7815
+ for (const line of sk.deleteWarnings(preview, {
7816
+ host,
7817
+ isFolderPick: key.id === folderKeyId,
7818
+ roleLabel: sk.roleOf(role).label,
7819
+ lastOfServer,
7820
+ })) {
7821
+ console.log(GUTTER + orange(` ${line}`));
7822
+ }
7823
+ const yes = await askYesNo(`Delete the API key "${key.name}" (····${key.tail})? This cannot be undone.`, false);
8048
7824
  if (!yes) {
8049
7825
  console.log(GUTTER + dim("(cancelled — nothing was deleted)\n"));
8050
7826
  paintBar();
8051
7827
  return;
8052
7828
  }
8053
- const out = await deleteCodingKey(victim.id);
7829
+ const out = await deleteCodingKey(key.id);
8054
7830
  if (!out.ok) {
8055
7831
  console.log(red(" couldn't delete the key — try again, or remove it in web Settings → Agent\n"));
8056
7832
  paintBar();
8057
7833
  return;
8058
7834
  }
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()));
7835
+ // A folder pointing at a deleted key would keep sending a dead id; put it back on the default.
7836
+ for (const r of ["coding", "agent", "embed"]) {
7837
+ const mine = r === "agent" ? sessionAgentKeyId : r === "embed" ? sessionEmbedKeyId : sessionCodingKeyId;
7838
+ if (mine === key.id) await saveFolderKeyPick(r, "");
8064
7839
  }
8065
- console.log(accent(` ✓ deleted ${out.name || victim.name}`) +
8066
- dim(` ····${victim.tail} · ${out.remaining} key(s) left\n`));
7840
+ console.log(accent(` ✓ deleted ${out.name || key.name}`) + dim(` ····${key.tail} · ${out.remaining} key(s) left\n`));
8067
7841
  paintBar();
8068
7842
  }
8069
7843
 
@@ -8641,6 +8415,10 @@ let sessionCodingModel = "";
8641
8415
  * a folder keeps meaning until someone picks.
8642
8416
  */
8643
8417
  let sessionCodingKeyId = "";
8418
+ // THIS FOLDER'S OWN KEY for the agent / the embedder (task #245) — per role, like the coder's.
8419
+ // "" = the server's default key, which is what a folder means until someone picks otherwise.
8420
+ let sessionAgentKeyId = "";
8421
+ let sessionEmbedKeyId = "";
8644
8422
  // Last 4 characters of the current API key, for display in the bottom bar
8645
8423
  let sessionApiKeyTail = "";
8646
8424
  // The friendly model name behind sessionCodingModel, for display only: the token footer
@@ -8936,24 +8714,17 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8936
8714
  // Deploy target chosen via /server — host/user only (no secret), for key-auth deploys.
8937
8715
  ...(selectedServer ? { deployServer: selectedServer } : {}),
8938
8716
  ...(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
8717
+ // THIS FOLDER'S OWN KEY PICKS, PER ROLE (task #245) — ids, never secrets, recorded on the
8718
+ // job so two folders' concurrent turns each resolve their own.
8949
8719
  //
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.
8720
+ // They were dropped once (#222 → the "folder names no key" rule) because an UNBOUND key
8721
+ // picked in a folder followed the folder from provider to provider, and each provider was
8722
+ // handed the other's credential (api.openai.com and api.moonshot.ai both 401'd). That is
8723
+ // now impossible at the API: a pick counts only when it is one of the turn's SERVER'S OWN
8724
+ // keys (resolveCodingApiKey), and anything else falls back to that server's default key.
8725
+ ...(sessionCodingKeyId ? { codingKeyId: sessionCodingKeyId } : {}),
8726
+ ...(sessionAgentKeyId ? { agentKeyId: sessionAgentKeyId } : {}),
8727
+ ...(sessionEmbedKeyId ? { embedKeyId: sessionEmbedKeyId } : {}),
8957
8728
  }),
8958
8729
  });
8959
8730
  const data = res ? await res.json().catch(() => ({})) : {};
@@ -10328,10 +10099,14 @@ async function main() {
10328
10099
  sessionCodingUrl = dbChoice.codingUrl;
10329
10100
  }
10330
10101
  if (dbChoice.codingKeyId) sessionCodingKeyId = dbChoice.codingKeyId;
10102
+ sessionAgentKeyId = dbChoice.agentKeyId;
10103
+ sessionEmbedKeyId = dbChoice.embedKeyId;
10331
10104
  // Refresh the local cache so the file matches the database.
10332
10105
  try {
10333
10106
  await updateSessionFile(resolve(process.cwd()), (raw) => {
10334
10107
  raw.codingKeyId = dbChoice.codingKeyId;
10108
+ raw.agentKeyId = dbChoice.agentKeyId;
10109
+ raw.embedKeyId = dbChoice.embedKeyId;
10335
10110
  raw.codingModel = dbChoice.codingModel;
10336
10111
  raw.codingLabel = dbChoice.codingLabel;
10337
10112
  raw.codingUrl = dbChoice.codingUrl;
@@ -10349,6 +10124,8 @@ async function main() {
10349
10124
  sessionCodingUrl = c.url;
10350
10125
  }
10351
10126
  sessionCodingKeyId = await loadSessionCodingKeyId(resolve(process.cwd())); // task #222
10127
+ sessionAgentKeyId = await loadSessionFolderKeyId(resolve(process.cwd()), "agent"); // task #245
10128
+ sessionEmbedKeyId = await loadSessionFolderKeyId(resolve(process.cwd()), "embed");
10352
10129
  }
10353
10130
  }
10354
10131
  // Fetch the API key tail after loading the session, if a key is configured