@tiens.nguyen/gu-cli 1.0.703 → 1.0.704

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.
@@ -466,3 +466,37 @@ export function agentProviderChoices(settings) {
466
466
  out.push({ action: "add", label: "add a new OpenAI-compatible provider…" });
467
467
  return out;
468
468
  }
469
+
470
+ // ---------------------------------------------------------------------------------------------
471
+ // The Ollama CLOUD's key is its OWN — and a key is never a key's name.
472
+ // ---------------------------------------------------------------------------------------------
473
+
474
+ /**
475
+ * The key saved FOR this provider row, or null.
476
+ *
477
+ * Reported: /agent-model → Ollama → https://ollama.com listed "20 on https://ollama.com" and saved the
478
+ * agent without asking for a key; the coder on ollama.com then failed "REJECTED the coding API key".
479
+ * Two things hid it. ollama.com's /api/tags is PUBLIC (200 with no key), so a successful list proves
480
+ * nothing about a key. And "a key for this provider" counted UNBOUND keys — by design usable with any
481
+ * OpenAI-compatible provider — so the account's sk-… key was taken as ollama.com's, and refused there.
482
+ * For the Ollama cloud only a key BOUND to its row counts.
483
+ */
484
+ export function boundKeyFor(keys, rowId) {
485
+ const id = String(rowId ?? "").trim();
486
+ if (!id || !Array.isArray(keys)) return null;
487
+ return keys.find((k) => k && k.backendId === id) ?? null;
488
+ }
489
+
490
+ /**
491
+ * Does what was typed as a key's NAME look like the key itself?
492
+ *
493
+ * Reported in the same log: a key named "sk-E8Iv…3E66" — the secret pasted into the name prompt — is
494
+ * printed in full wherever keys are listed ("API key sk-E8Iv… ····3E66"). Names are shown; secrets
495
+ * must not be. Caught on the known prefixes, and on anything long, unbroken and mixed like a token.
496
+ */
497
+ export function looksLikeSecret(name) {
498
+ const v = String(name ?? "").trim();
499
+ if (!v) return false;
500
+ if (/^(sk-|sk_|pk-|rk-|ghp_|gho_|github_pat_|xox[abp]-|AKIA|AIza|hf_|gsk_|ollama_)/i.test(v)) return true;
501
+ return v.length >= 24 && !/\s/.test(v) && /[A-Za-z]/.test(v) && /\d/.test(v);
502
+ }
@@ -753,14 +753,17 @@ export function mergeOllamaServerBackends(existing, fresh, { isCloud } = {}) {
753
753
  const cloud = typeof isCloud === "function" ? isCloud : () => false;
754
754
  const norm = (u) => String(u ?? "").trim().replace(/\/+$/, "").toLowerCase();
755
755
  const rows = Array.isArray(existing) ? existing.filter(Boolean) : [];
756
- const replaced = rows.filter((b) => b.kind === "ollama" && !cloud(b.url));
757
- const kept = rows.filter((b) => !(b.kind === "ollama" && !cloud(b.url)));
756
+ const freshOllama = (Array.isArray(fresh) ? fresh : []).filter((f) => f.kind === "ollama");
757
+ const newUrls = new Set(freshOllama.map((f) => norm(f.url)));
758
+ // A row already at the NEW server's URL is the same provider — the new server takes it over, id and
759
+ // all, so a key bound to it (the Ollama cloud's own key) keeps working and no twin row appears.
760
+ const sameServer = rows.filter((b) => newUrls.has(norm(b.url)));
761
+ const replaced = [...sameServer, ...rows.filter((b) => b.kind === "ollama" && !cloud(b.url) && !sameServer.includes(b))];
762
+ const kept = rows.filter((b) => !replaced.includes(b));
758
763
  const out = [];
759
- for (const f of Array.isArray(fresh) ? fresh : []) {
760
- if (f.kind === "ollama") {
761
- const id = replaced.find((b) => b.id)?.id;
762
- out.push(id ? { ...f, id } : f);
763
- }
764
+ for (const f of freshOllama) {
765
+ const id = (sameServer.find((b) => b.id && norm(b.url) === norm(f.url)) ?? replaced.find((b) => b.id))?.id;
766
+ out.push(id ? { ...f, id } : f);
764
767
  }
765
768
  out.push(...kept);
766
769
  for (const f of Array.isArray(fresh) ? fresh : []) {
package/gu-repl.mjs CHANGED
@@ -112,7 +112,7 @@ import {
112
112
  shouldCancelPrompt,
113
113
  } from "./terminal-layout.mjs";
114
114
  import { completedFlush } from "./completed-flush.mjs";
115
- import { codingBannerLabel, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
115
+ import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
116
116
  import { codingChoiceTags, coderDisplayName, ollamaWhere } from "./coding-choice-tags.mjs";
117
117
  import {
118
118
  isDoubleClick,
@@ -4955,6 +4955,28 @@ async function reconfigureOllamaServer() {
4955
4955
  console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
4956
4956
  return;
4957
4957
  }
4958
+ // THE OLLAMA CLOUD NEEDS ITS OWN KEY. Reported: choosing https://ollama.com here listed its 20 models
4959
+ // (that list is public) and saved, without a key — and the next turn failed "REJECTED the coding API
4960
+ // key", having borrowed an unrelated sk-… key. Asked for now, bound to the row just saved.
4961
+ {
4962
+ const cloudUrls = [coder?.normalized?.url, agent?.normalized?.url].filter(
4963
+ (u, i, all) => u && all.indexOf(u) === i && isOllamaCloudUrl(u)
4964
+ );
4965
+ if (cloudUrls.length) {
4966
+ const fresh = await withPendingStatus("checking the Ollama cloud key", fetchWorkerSettings);
4967
+ const keys = await fetchCodingKeys();
4968
+ const ps = await import("./coding-provider-setup.mjs");
4969
+ for (const u of cloudUrls) {
4970
+ const row = ps
4971
+ .existingBackendRows(fresh ?? {})
4972
+ .find((b) => ps.ensureV1Root(String(b.url ?? "")).toLowerCase() === ps.ensureV1Root(u).toLowerCase());
4973
+ if (row && !am.boundKeyFor(keys, row.id)) {
4974
+ console.log(yellow(` ${hostOf(u)} is the Ollama cloud — it needs its own API key (the model list worked without one).`));
4975
+ await askKeyForRow(row, hostOf(u));
4976
+ }
4977
+ }
4978
+ }
4979
+ }
4958
4980
  // THE ACCOUNT DEFAULT JUST CHANGED — say so in memory, or the bar reports the old one.
4959
4981
  //
4960
4982
  // The override is cleared just below, correctly: what was picked here IS the account default
@@ -5514,7 +5536,7 @@ async function addCodingProvider(roleName = "coding") {
5514
5536
  // NOT DEFAULTED, by request. An empty name becomes "Unknown" at the API, which is the same
5515
5537
  // thing every other add-a-key path produces — the URL default was asked for, this was not.
5516
5538
  console.log("\n" + GUTTER + dim('A name just tells keys apart — "work", "personal".'));
5517
- const name = (await ask(" key name: ")).trim();
5539
+ const name = await askKeyName();
5518
5540
  console.log(GUTTER + dim(SECRET_KEY_HINT));
5519
5541
  const secret = (await askSecret(` API key${name ? ` for ${name}` : ""}: `)).trim();
5520
5542
  if (!secret) {
@@ -6048,10 +6070,25 @@ async function saveAgentModel(agentUrl, model, host) {
6048
6070
  return true;
6049
6071
  }
6050
6072
 
6073
+ /**
6074
+ * Ask for a key's NAME — and refuse the key itself as a name: names are printed wherever keys are
6075
+ * listed, so a pasted secret there is a secret on screen (reported: "API key sk-E8Iv… ····3E66").
6076
+ */
6077
+ async function askKeyName(prompt = " key name: ") {
6078
+ const am = await import("./agent-model-command.mjs");
6079
+ for (let attempt = 0; attempt < 2; attempt++) {
6080
+ const name = (await ask(prompt)).trim();
6081
+ if (!am.looksLikeSecret(name)) return name;
6082
+ console.log(yellow(" that looks like the API key itself — the NAME is shown on screen, so it must not be the key."));
6083
+ console.log(GUTTER + dim('type a short label like "work", or press Enter for none — the key comes next.'));
6084
+ }
6085
+ return "";
6086
+ }
6087
+
6051
6088
  /** Ask for a key and store it BOUND to this provider row. Returns the secret, or "" if cancelled. */
6052
6089
  async function askKeyForRow(row, host) {
6053
6090
  console.log("\n" + GUTTER + dim('A name just tells keys apart — "work", "personal".'));
6054
- const name = (await ask(" key name: ")).trim();
6091
+ const name = await askKeyName();
6055
6092
  console.log(GUTTER + dim(SECRET_KEY_HINT));
6056
6093
  const secret = (await askSecret(` API key for ${host}${name ? ` (${name})` : ""}: `)).trim();
6057
6094
  if (!secret) {
@@ -6186,9 +6223,15 @@ async function addOllamaAgent() {
6186
6223
  : null;
6187
6224
  let key = "";
6188
6225
  let newRow = null;
6226
+ let hasBoundKey = false;
6189
6227
  if (target.cloud) {
6190
- if (row) key = (await withPendingStatus(`reading the key for ${target.host}`, () => fetchCodingKey(String(row.id ?? "")))).key;
6191
- if (!key) {
6228
+ // A KEY SAVED FOR THIS PROVIDER — not any key the account holds. ollama.com's model list answers
6229
+ // without a key, so listing cannot catch a borrowed one; only asking can.
6230
+ if (row) {
6231
+ const keys = await withPendingStatus(`reading the keys for ${target.host}`, fetchCodingKeys);
6232
+ hasBoundKey = Boolean(am.boundKeyFor(keys, row.id));
6233
+ }
6234
+ if (!hasBoundKey) {
6192
6235
  if (!row) {
6193
6236
  if (existing.length >= ps.MAX_BACKENDS) {
6194
6237
  console.log(yellow(` ${ps.MAX_BACKENDS} providers is the limit — remove one in web Settings → Agent first.\n`));
@@ -6251,7 +6294,7 @@ async function addOllamaAgent() {
6251
6294
  row = newRow;
6252
6295
  console.log(GUTTER + dim(`${target.host} is also offered in /model now.`));
6253
6296
  }
6254
- if (target.cloud && key && !(await fetchCodingKey(String(row?.id ?? ""))).key) {
6297
+ if (target.cloud && key && !hasBoundKey) {
6255
6298
  const id = await withPendingStatus("saving the key", () => addCodingKey("Ollama cloud", key, String(row?.id ?? "")));
6256
6299
  if (!id) {
6257
6300
  console.log(red(" couldn't save the key — the agent was not changed.\n"));
@@ -7191,7 +7234,7 @@ async function deleteCodingKey(id) {
7191
7234
  */
7192
7235
  async function promptNewCodingKey(backendId = "", provider = "") {
7193
7236
  console.log("\n" + GUTTER + dim("A name just tells them apart — \"work\", \"personal\".\n"));
7194
- const name = (await ask(" Name for this key: ")).trim();
7237
+ const name = await askKeyName(" Name for this key: ");
7195
7238
  console.log(GUTTER + dim(SECRET_KEY_HINT));
7196
7239
  const secret = (await askSecret(` API key${name ? ` for ${name}` : ""}: `)).trim();
7197
7240
  if (!secret) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.703",
3
+ "version": "1.0.704",
4
4
  "description": "Gu CLI — the gu terminal plus the local worker that runs agent / OCR / PDF / embedding jobs on your Mac (Ollama / MLX / OpenAI-compatible).",
5
5
  "type": "module",
6
6
  "license": "MIT",