@tiens.nguyen/gu-cli 1.0.699 → 1.0.701

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.
@@ -349,3 +349,120 @@ export function agentRecoveryOptions({ keyProblem = false, host = "" } = {}) {
349
349
  { action: "provider", label: "add an OpenAI-compatible provider for the agent…" },
350
350
  ];
351
351
  }
352
+
353
+ // ---------------------------------------------------------------------------------------------
354
+ // #43 — /model's "change the Ollama server" flow, when the agent is NOT on an Ollama.
355
+ // ---------------------------------------------------------------------------------------------
356
+
357
+ /**
358
+ * Is this machine's agent model on an OpenAI-compatible PROVIDER rather than an Ollama?
359
+ *
360
+ * Reported (#43): client mode, /model → change the Ollama server offered the agent's URL —
361
+ * https://api.openai.com — as the "agent + embedding server", probed it for Ollama's /api/tags, got
362
+ * 404, said "check that Ollama is listening", and asked again, with no way forward but typing "no".
363
+ * A provider is known two ways: a provider row for that URL says so, or the URL is one of the hosted
364
+ * APIs everyone knows is not an Ollama.
365
+ */
366
+ const KNOWN_PROVIDER_HOSTS = [
367
+ "api.openai.com",
368
+ "openrouter.ai",
369
+ "api.together.xyz",
370
+ "api.moonshot.ai",
371
+ "api.groq.com",
372
+ "api.deepseek.com",
373
+ "api.mistral.ai",
374
+ "generativelanguage.googleapis.com",
375
+ ];
376
+
377
+ export function agentIsOnProvider(settings) {
378
+ const source = agentListSource(settings);
379
+ if (!source.url) return false;
380
+ if (source.dialect === "openai") return true;
381
+ let host = "";
382
+ try {
383
+ host = new URL(String(settings?.agentModelUrl ?? "")).hostname.toLowerCase();
384
+ } catch {
385
+ return false;
386
+ }
387
+ return KNOWN_PROVIDER_HOSTS.some((h) => host === h || host.endsWith(`.${h}`));
388
+ }
389
+
390
+ /**
391
+ * The address to offer for the CODING server: the coder's own URL when it is not a known provider,
392
+ * else this machine's Ollama. Never the agent's provider URL — that is what put api.openai.com in an
393
+ * Ollama prompt.
394
+ */
395
+ export function coderServerDefault(settings) {
396
+ const coder = String(settings?.agentCodingModelUrl ?? "").trim();
397
+ if (coder && !agentIsOnProvider({ ...settings, agentModelUrl: coder })) return coder;
398
+ const agent = String(settings?.agentModelUrl ?? "").trim();
399
+ if (agent && !agentIsOnProvider(settings)) return agent;
400
+ return "http://127.0.0.1:11434";
401
+ }
402
+
403
+ /**
404
+ * A server that ANSWERED but has no /api/tags is not an Ollama — an OpenAI-compatible API answers 404
405
+ * there. That is a different fix from "not listening", and saying the wrong one sends people to check
406
+ * a server that is working fine.
407
+ */
408
+ export function describeNotOllama(host, role) {
409
+ return role === "agent"
410
+ ? [
411
+ `${host} answers, but it is not an Ollama server (no /api/tags).`,
412
+ "an OpenAI-compatible agent is changed with /agent-model — or type 'no' to skip this role.",
413
+ ]
414
+ : [
415
+ `${host} answers, but it is not an Ollama server (no /api/tags).`,
416
+ "for an OpenAI-compatible coder use /model → add or change an OpenAI-compatible provider — or type 'no' to skip.",
417
+ ];
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------------------------
421
+ // #44 — WHAT TO CHANGE comes first.
422
+ // ---------------------------------------------------------------------------------------------
423
+
424
+ /**
425
+ * The first screen of /agent-model: what to change, before any model list.
426
+ *
427
+ * Reported (#44): client mode, agent on api.openai.com — /agent-model opened straight onto "130 on
428
+ * https://api.openai.com/v1" with the current model selected, and the ways to use an Ollama server
429
+ * or another provider sat after all 130 rows. Enter said "already using gpt-4.1-mini — nothing
430
+ * changed", and the only thing the screen seemed to allow was another OpenAI model. First-time setup
431
+ * worked; changing it afterwards did not look possible.
432
+ *
433
+ * So the choice of WHAT to change is its own short list, in the order it is most often wanted. The
434
+ * key entry appears only where a key applies. Returns [{ action, label }].
435
+ */
436
+ export function agentMenuOptions({ host = "", modelCount = 0, needsKey = false } = {}) {
437
+ const where = host || "this server";
438
+ return [
439
+ { action: "models", label: `pick another model on ${where}${modelCount ? ` (${modelCount})` : ""}` },
440
+ { action: "ollama", label: "use an Ollama server for the agent…" },
441
+ { action: "provider", label: "use another OpenAI-compatible provider…" },
442
+ ...(needsKey ? [{ action: "key", label: `enter a new API key for ${where}…` }] : []),
443
+ ];
444
+ }
445
+
446
+ /**
447
+ * The providers already saved on this machine that the agent could switch to — OpenAI-compatible
448
+ * rows, current one marked — then "add a new one". Switching to a provider you already set up must
449
+ * not mean typing its URL again. Returns [{ action: "row" | "add", row?, label }].
450
+ */
451
+ export function agentProviderChoices(settings) {
452
+ const rows = Array.isArray(settings?.agentCodingBackends) ? settings.agentCodingBackends : [];
453
+ const current = ensureV1Root(String(settings?.agentModelUrl ?? "")).toLowerCase();
454
+ const out = [];
455
+ for (const row of rows) {
456
+ if (!row || row.kind !== "openai" || !row.url) continue;
457
+ let host = String(row.url);
458
+ try {
459
+ host = new URL(String(row.url)).host;
460
+ } catch {
461
+ /* keep the raw url */
462
+ }
463
+ const isCurrent = ensureV1Root(String(row.url)).toLowerCase() === current;
464
+ out.push({ action: "row", row, label: `${host}${isCurrent ? " (current)" : ""}` });
465
+ }
466
+ out.push({ action: "add", label: "add a new OpenAI-compatible provider…" });
467
+ return out;
468
+ }
@@ -734,3 +734,37 @@ export async function seedSettings({
734
734
  await client.close().catch(() => {});
735
735
  }
736
736
  }
737
+
738
+ /**
739
+ * THE NEW OLLAMA SERVER'S ROWS, MERGED INTO WHAT IS ALREADY THERE — never replacing it (#43).
740
+ *
741
+ * Found while fixing #43: choosing a coding server in /model → "change the Ollama server" saved
742
+ * codingBackendsForClientExpert(...) AS the whole list — one Ollama row and the Kimi row — deleting
743
+ * every provider the user had added: an api.openai.com row the AGENT's key is found through, an
744
+ * ollama.com cloud row. Their keys, bound to those row ids, were orphaned.
745
+ *
746
+ * Replaced: the local/LAN Ollama row(s) — that is what "the Ollama server this machine uses" means —
747
+ * with the new one, which keeps the first replaced row's id so anything bound to it still resolves.
748
+ * Kept, ids and all: every OpenAI-compatible row, and any Ollama CLOUD row (a paid provider, not
749
+ * "this machine's server"). The template's default provider row is added only when no row already
750
+ * has its URL.
751
+ */
752
+ export function mergeOllamaServerBackends(existing, fresh, { isCloud } = {}) {
753
+ const cloud = typeof isCloud === "function" ? isCloud : () => false;
754
+ const norm = (u) => String(u ?? "").trim().replace(/\/+$/, "").toLowerCase();
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)));
758
+ 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
+ }
765
+ out.push(...kept);
766
+ for (const f of Array.isArray(fresh) ? fresh : []) {
767
+ if (f.kind !== "ollama" && !out.some((b) => norm(b.url) === norm(f.url))) out.push(f);
768
+ }
769
+ return out;
770
+ }
@@ -9694,22 +9694,25 @@ def run_agent_chat(cfg):
9694
9694
  f"{_host} refused: "
9695
9695
  + (_why or "the account is out of credit")
9696
9696
  + ". Nothing ran — top up the account, or switch the "
9697
- "coding model in Settings → Agent."
9697
+ "coding model (in gu: /model; on the web: Settings → Agent)."
9698
9698
  ) from e
9699
+ # THE TERMINAL WAY FIRST (#44): a client-mode machine has no web app,
9700
+ # so "Settings → Agent" alone was advice it could not follow.
9699
9701
  if coding_api_key:
9700
9702
  raise _AgentConfigError(
9701
9703
  f"The agent coding backend ({_host}) REJECTED the "
9702
- "coding API key. Nothing ran. Re-enter 'Agent coding "
9703
- "model API key' in Settings → Agent (the stored key is "
9704
- "write-only, so a stale one can't be inspected — just "
9705
- "save a fresh one)."
9704
+ "coding API key. Nothing ran. Enter a fresh key — in gu: "
9705
+ "press ctrl+k (or /model → the provider); on the web: "
9706
+ "Settings → Agent. The stored key is write-only, so a "
9707
+ "stale one can't be inspected — just save a fresh one."
9706
9708
  ) from e
9707
9709
  raise _AgentConfigError(
9708
9710
  f"The agent coding backend ({_host}) rejected the request: "
9709
9711
  "NO coding API key was sent, so the local agent key was "
9710
- "used instead. Enter the API key in Settings → Agent for "
9711
- "this coding backend (OpenAI-compatible, or Ollama with an "
9712
- "https://ollama.com URL — local Ollama needs no key)."
9712
+ "used instead. Enter the API key for this coding backend — in "
9713
+ "gu: press ctrl+k; on the web: Settings → Agent "
9714
+ "(OpenAI-compatible, or Ollama with an https://ollama.com URL — "
9715
+ "local Ollama needs no key)."
9713
9716
  ) from e
9714
9717
  if "404" in emsg and "not found" in emsg.lower():
9715
9718
  # Probe with the CODING key when there is one: using the agent
package/gu-repl.mjs CHANGED
@@ -4721,12 +4721,13 @@ async function reconfigureOllamaServer() {
4721
4721
  const res = await withPendingStatus(`loading models from ${n.host}`, () =>
4722
4722
  proxyFetch(os.tagsUrlFor(n), { signal: AbortSignal.timeout(8000) })
4723
4723
  );
4724
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
4724
+ // The status is kept: a 404 here means "answered, but not an Ollama" (#43), not "unreachable".
4725
+ if (!res.ok) throw Object.assign(new Error(`HTTP ${res.status}`), { httpStatus: res.status });
4725
4726
  return am.parseOllamaTags(await res.json().catch(() => null));
4726
4727
  };
4727
4728
 
4728
4729
  /** Ask for one server and load it. Returns {normalized, models} or null (declined/failed). */
4729
- const askServer = async (label, dflt, { require = false, short = "server" } = {}) => {
4730
+ const askServer = async (label, dflt, { require = false, short = "server", role = "coder" } = {}) => {
4730
4731
  for (let attempt = 0; attempt < 3; attempt++) {
4731
4732
  // The address goes ABOVE, in full — it is long, it is the thing Enter accepts, and the
4732
4733
  // frozen row is too narrow to hold it without truncating exactly that. The row itself
@@ -4766,6 +4767,12 @@ async function reconfigureOllamaServer() {
4766
4767
  }
4767
4768
  }
4768
4769
  if (!n) {
4770
+ if (lastErr?.httpStatus === 404) {
4771
+ // IT ANSWERED — it is simply not an Ollama (#43). "Couldn't reach … is Ollama listening"
4772
+ // sent people to debug a server that was working.
4773
+ for (const l of am.describeNotOllama(candidates[0].host, role)) console.log(yellow(` ${l}`));
4774
+ continue;
4775
+ }
4769
4776
  // One address typed, one failure reported — naming every candidate would be two errors
4770
4777
  // for one mistake, and neither would be the address they gave.
4771
4778
  console.log(red(` couldn't reach ${candidates[0].host}: ${os.networkReason(lastErr)}`));
@@ -4795,18 +4802,31 @@ async function reconfigureOllamaServer() {
4795
4802
  return null;
4796
4803
  };
4797
4804
 
4805
+ // AN AGENT ON A PROVIDER IS NOT ASKED ABOUT HERE (#43). This flow is for Ollama boxes; offering
4806
+ // https://api.openai.com as the "agent + embedding server" could only fail its /api/tags probe and
4807
+ // ask again. Keep it, say where it is changed, and go on to the coder.
4808
+ const agentOnProvider = am.agentIsOnProvider(settings);
4798
4809
  const curAgent =
4799
4810
  String(settings.agentModelUrl ?? "").trim() || "https://ollama1.gomarsic.cc/v1";
4800
- const curCode = String(settings.agentCodingModelUrl ?? "").trim() || curAgent;
4811
+ const curCode = am.coderServerDefault(settings);
4801
4812
 
4802
- console.log(dim(" TWO servers may be used: the agent + embedder on one, the coder on another."));
4803
- console.log(dim(" Enter keeps the current address; type 'no' to skip a role."));
4804
- const agent = await askServer("Agent + embedding server", curAgent, {
4805
- require: true,
4806
- short: "agent + embed server",
4807
- });
4813
+ let agent = null;
4814
+ if (agentOnProvider) {
4815
+ const host = hostOf(String(settings.agentModelUrl ?? ""));
4816
+ console.log(dim(` The agent model is on ${host} (OpenAI-compatible) — kept as it is; change it with /agent-model.`));
4817
+ console.log(dim(" Enter keeps the current address; type 'no' to skip."));
4818
+ } else {
4819
+ console.log(dim(" TWO servers may be used: the agent + embedder on one, the coder on another."));
4820
+ console.log(dim(" Enter keeps the current address; type 'no' to skip a role."));
4821
+ agent = await askServer("Agent + embedding server", curAgent, {
4822
+ require: true,
4823
+ short: "agent + embed server",
4824
+ role: "agent",
4825
+ });
4826
+ }
4808
4827
  const coder = await askServer("Coding-model server", agent?.normalized?.url ?? curCode, {
4809
4828
  short: "coding server",
4829
+ role: "coder",
4810
4830
  });
4811
4831
 
4812
4832
  if (!agent && !coder) {
@@ -4912,12 +4932,18 @@ async function reconfigureOllamaServer() {
4912
4932
  agentCodingModelName: code,
4913
4933
  agentCodingModelKind: "ollama",
4914
4934
  agentCodingDefaultKind: "ollama",
4915
- agentCodingBackends: (await import("./client-expert-api.mjs")).codingBackendsForClientExpert({
4916
- url: coder.normalized.url,
4917
- chat: agent ? chat : code,
4918
- code,
4919
- altCoders: extras,
4920
- }),
4935
+ // MERGED, not replaced (#43): the providers already configured — and the keys bound to them —
4936
+ // survive choosing a new Ollama server.
4937
+ agentCodingBackends: (await import("./client-expert-api.mjs")).mergeOllamaServerBackends(
4938
+ (await import("./coding-provider-setup.mjs")).existingBackendRows(settings),
4939
+ (await import("./client-expert-api.mjs")).codingBackendsForClientExpert({
4940
+ url: coder.normalized.url,
4941
+ chat: agent ? chat : code,
4942
+ code,
4943
+ altCoders: extras,
4944
+ }),
4945
+ { isCloud: (await import("./coding-key-prompt.mjs")).isOllamaCloudUrl }
4946
+ ),
4921
4947
  });
4922
4948
  }
4923
4949
 
@@ -6093,6 +6119,29 @@ async function pointAgentAtProvider(row, host) {
6093
6119
  await saveAgentModel(url, model, host);
6094
6120
  }
6095
6121
 
6122
+ /**
6123
+ * #44: switch the agent to a provider already saved on this machine, or add a new one.
6124
+ */
6125
+ async function chooseAgentProvider() {
6126
+ const am = await import("./agent-model-command.mjs");
6127
+ const settings = await withPendingStatus("reading your providers", fetchWorkerSettings);
6128
+ if (!settings) {
6129
+ console.log(red(" couldn't reach the API to read your providers.\n"));
6130
+ return;
6131
+ }
6132
+ const choices = am.agentProviderChoices(settings);
6133
+ if (choices.length === 1) return addCodingProvider("agent");
6134
+ console.log(dim(` Which OpenAI-compatible provider should the agent use? (${PICK_HINT})`));
6135
+ const idx = await pickFromList(choices.map((c) => c.label), 0);
6136
+ if (idx < 0) {
6137
+ console.log(dim(" (cancelled)\n"));
6138
+ return;
6139
+ }
6140
+ const pick = choices[idx];
6141
+ if (pick.action === "add") return addCodingProvider("agent");
6142
+ await pointAgentAtProvider(pick.row, hostOf(String(pick.row.url ?? "")));
6143
+ }
6144
+
6096
6145
  /**
6097
6146
  * #41: the agent model on an OLLAMA server — this machine's, a LAN box, or the Ollama cloud.
6098
6147
  *
@@ -6308,6 +6357,21 @@ async function chooseAgentModel() {
6308
6357
  return;
6309
6358
  }
6310
6359
 
6360
+ // WHAT TO CHANGE, FIRST (#44). The ways to use an Ollama server or another provider used to sit
6361
+ // after every model on this one — 130 rows down on api.openai.com — which read as "OpenAI models
6362
+ // are all you can choose". A short menu of what to change comes before any list.
6363
+ console.log(dim(` Agent model — the one that TALKS: ${current || "unset"} on ${listHost}`));
6364
+ const menu = am.agentMenuOptions({ host: listHost, modelCount: models.length, needsKey });
6365
+ const first = await pickFromList(menu.map((o) => o.label), 0);
6366
+ const firstAction = first >= 0 ? menu[first].action : "cancel";
6367
+ if (firstAction === "cancel") {
6368
+ console.log(dim(" (cancelled)\n"));
6369
+ return;
6370
+ }
6371
+ if (firstAction === "ollama") return addOllamaAgent();
6372
+ if (firstAction === "provider") return chooseAgentProvider();
6373
+ if (firstAction === "key") return enterAgentProviderKey(source.row, listHost);
6374
+
6311
6375
  const rows = am.agentModelRows(models, current);
6312
6376
  console.log(dim(` Agent model — the one that TALKS (currently ${current || "unset"})`));
6313
6377
  // Strip whichever list endpoint was asked, so the header names the SERVER rather than the path.
@@ -6334,28 +6398,13 @@ async function chooseAgentModel() {
6334
6398
  (r.current ? accent(" (current)") : "")
6335
6399
  );
6336
6400
  });
6337
- // APPENDED LAST, after every model, for the reason #223 records: a new entry must never shift
6338
- // an index someone already has muscle memory for. Offered even when the list is healthy,
6339
- // because "which model on the box I am pointed at" and "I want a different provider" are
6340
- // different questions, and only the first is answered by the rows above.
6341
- labels.push("add an OpenAI-compatible provider for the agent…");
6342
- // #41, appended AFTER the provider entry so no existing index moves.
6343
- labels.push("use an Ollama server for the agent…");
6401
+ // No other entries here any more (#44): the ways to change the provider are on the menu above,
6402
+ // where they can be seen, rather than after every model.
6344
6403
  const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
6345
6404
  if (idx < 0) {
6346
6405
  console.log(dim(" (cancelled)\n"));
6347
6406
  return;
6348
6407
  }
6349
- // labels is now TWO LONGER than rows. Reading rows[idx] for an appended entry would
6350
- // dereference undefined and take the picker down, so they are answered before rows is touched.
6351
- if (idx === rows.length) {
6352
- await addCodingProvider("agent");
6353
- return;
6354
- }
6355
- if (idx === rows.length + 1) {
6356
- await addOllamaAgent();
6357
- return;
6358
- }
6359
6408
  const chosen = rows[idx].model;
6360
6409
  if (chosen === current) {
6361
6410
  console.log(dim(` already using ${current} — nothing changed.\n`));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.699",
3
+ "version": "1.0.701",
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",