@tiens.nguyen/gu-cli 1.0.698 → 1.0.700

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.
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  import { modelRetired, ensureV1Root } from "./coding-provider-setup.mjs";
21
+ import { isOllamaCloudUrl } from "./coding-key-prompt.mjs";
21
22
 
22
23
 
23
24
  /**
@@ -257,3 +258,161 @@ export function agentListSource(settings) {
257
258
  export function agentModelsFromBody(dialect, body, now = Date.now()) {
258
259
  return dialect === "openai" ? agentModelsFromProvider(body, now) : parseOllamaTags(body);
259
260
  }
261
+
262
+ // ---------------------------------------------------------------------------------------------
263
+ // #40 and #41 — the agent model on an OpenAI-compatible provider WITH ITS KEY, and on any Ollama.
264
+ // ---------------------------------------------------------------------------------------------
265
+
266
+ /**
267
+ * Does listing (and using) this agent source need an API key?
268
+ *
269
+ * Reported (#40): /agent-model on api.openai.com said "HTTP 401" — the list was fetched with no
270
+ * Authorization header at all, so a provider that requires a key could never be listed, however
271
+ * correct the stored key was. An OpenAI-compatible row needs one; so does the Ollama CLOUD. A local
272
+ * or LAN Ollama does not.
273
+ */
274
+ export function agentSourceNeedsKey(source) {
275
+ const row = source?.row;
276
+ if (!row) return false;
277
+ return source.dialect === "openai" || isOllamaCloudUrl(String(row.url ?? ""));
278
+ }
279
+
280
+ /** The headers a model-list request needs — the bearer only when there is a key to send. */
281
+ export function agentListHeaders(key) {
282
+ const k = String(key ?? "").trim();
283
+ return k ? { Authorization: `Bearer ${k}` } : {};
284
+ }
285
+
286
+ /**
287
+ * An Ollama server as typed → where the agent points, and where its models are listed (#41).
288
+ *
289
+ * Forgiving about what people paste — "127.0.0.1:11434", "http://box:11434/v1", ".../api/tags",
290
+ * "ollama.com" — and strict about the result: the agent field gets `${root}/v1` (the shape every
291
+ * existing install stores, see daemon-control.mjs), the list is `${root}/api/tags`. A bare host is
292
+ * http, except ollama.com, which only answers https.
293
+ *
294
+ * Returns { ok: true, root, agentUrl, tagsUrl, host, cloud } or { ok: false, why }.
295
+ */
296
+ export function ollamaAgentUrl(raw) {
297
+ let v = String(raw ?? "").trim();
298
+ if (!v) return { ok: false, why: "empty" };
299
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) {
300
+ const bareHost = v.split(/[/:]/)[0].toLowerCase();
301
+ v = `${bareHost === "ollama.com" || bareHost.endsWith(".ollama.com") ? "https" : "http"}://${v}`;
302
+ }
303
+ let u;
304
+ try {
305
+ u = new URL(v);
306
+ } catch {
307
+ return { ok: false, why: "not a URL" };
308
+ }
309
+ if (u.protocol !== "http:" && u.protocol !== "https:") return { ok: false, why: "not http(s)" };
310
+ if (!u.hostname) return { ok: false, why: "no host" };
311
+ const path = u.pathname.replace(/\/+$/, "").replace(/\/(v1|api\/tags|api)$/i, "").replace(/\/+$/, "");
312
+ const root = `${u.protocol}//${u.host}${path}`;
313
+ return {
314
+ ok: true,
315
+ root,
316
+ agentUrl: `${root}/v1`,
317
+ tagsUrl: `${root}/api/tags`,
318
+ host: u.host,
319
+ cloud: isOllamaCloudUrl(root),
320
+ };
321
+ }
322
+
323
+ /**
324
+ * The server to offer by default: the one the agent already uses when that is an Ollama, else this
325
+ * machine's own Ollama.
326
+ */
327
+ export function ollamaAgentDefault(settings) {
328
+ const source = agentListSource(settings);
329
+ if (source.url && source.dialect === "ollama") {
330
+ const r = ollamaAgentUrl(String(settings?.agentModelUrl ?? ""));
331
+ if (r.ok) return r.root;
332
+ }
333
+ return "http://127.0.0.1:11434";
334
+ }
335
+
336
+ /**
337
+ * The ways forward /agent-model offers when the list could not be read — and in which order.
338
+ *
339
+ * A KEY PROBLEM GETS ITS OWN ENTRY, FIRST (#40): a 401, or a provider that needs a key with none
340
+ * stored, is fixed by entering the key — not by "adding a provider", which for an already-configured
341
+ * URL could only answer "already configured here". Returns [{ action, label }]; "leave" is always
342
+ * first so Esc and Enter-on-the-top-row both change nothing.
343
+ */
344
+ export function agentRecoveryOptions({ keyProblem = false, host = "" } = {}) {
345
+ return [
346
+ { action: "leave", label: "leave it" },
347
+ ...(keyProblem ? [{ action: "key", label: `enter the API key for ${host || "this provider"}…` }] : []),
348
+ { action: "ollama", label: "use an Ollama server for the agent…" },
349
+ { action: "provider", label: "add an OpenAI-compatible provider for the agent…" },
350
+ ];
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
+ }
@@ -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
+ }
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
 
@@ -5459,6 +5485,14 @@ async function addCodingProvider(roleName = "coding") {
5459
5485
  // ALREADY CONFIGURED is its own answer. Saving a second row for the same URL would give the
5460
5486
  // picker two rows that are identical in everything a person can see.
5461
5487
  const clash = existing.find((b) => ps.ensureV1Root(String(b.url ?? "")) === r.url);
5488
+ if (clash && roleName === "agent") {
5489
+ // FOR THE AGENT, "already configured" IS THE ANSWER, NOT A REFUSAL (#40). The provider and
5490
+ // its key may well be there already; what is missing is the agent pointing at it. Sending the
5491
+ // user to /model — the CODING picker — left no way to finish from here.
5492
+ console.log(GUTTER + dim(`${r.host} is already configured — using it for the agent.`));
5493
+ await pointAgentAtProvider(clash, r.host);
5494
+ return;
5495
+ }
5462
5496
  if (clash) {
5463
5497
  console.log(GUTTER + dim(`${r.host} is already configured here — pick its model in /model.`));
5464
5498
  continue;
@@ -5998,6 +6032,212 @@ async function chooseEmbedModel() {
5998
6032
  console.log(dim(` Make sure it is pulled there: ollama pull ${chosen}\n`));
5999
6033
  }
6000
6034
 
6035
+ /** Save the agent model on this machine: the URL the API sends turns to, and the model name. */
6036
+ async function saveAgentModel(agentUrl, model, host) {
6037
+ const md = await import("./model-doctor.mjs");
6038
+ const ok = await withPendingStatus("pointing the agent model at it", () =>
6039
+ md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: agentUrl, agentModelName: model, agentModel: model })
6040
+ );
6041
+ if (!ok) {
6042
+ console.log(red(` couldn't save the agent model — nothing changed.`));
6043
+ console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
6044
+ return false;
6045
+ }
6046
+ console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
6047
+ console.log(dim(" takes effect on your next question.\n"));
6048
+ return true;
6049
+ }
6050
+
6051
+ /** Ask for a key and store it BOUND to this provider row. Returns the secret, or "" if cancelled. */
6052
+ async function askKeyForRow(row, host) {
6053
+ console.log("\n" + GUTTER + dim('A name just tells keys apart — "work", "personal".'));
6054
+ const name = (await ask(" key name: ")).trim();
6055
+ console.log(GUTTER + dim(SECRET_KEY_HINT));
6056
+ const secret = (await askSecret(` API key for ${host}${name ? ` (${name})` : ""}: `)).trim();
6057
+ if (!secret) {
6058
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6059
+ return "";
6060
+ }
6061
+ const id = await withPendingStatus("saving the key", () => addCodingKey(name, secret, String(row?.id ?? "")));
6062
+ if (!id) {
6063
+ console.log(red(" couldn't save the key — nothing changed.\n"));
6064
+ return "";
6065
+ }
6066
+ return secret;
6067
+ }
6068
+
6069
+ /** #40: the key was the problem — enter it, then list again. */
6070
+ async function enterAgentProviderKey(row, host) {
6071
+ if (!row) return;
6072
+ if (!(await askKeyForRow(row, host))) return;
6073
+ await chooseAgentModel();
6074
+ }
6075
+
6076
+ /**
6077
+ * #40: point the agent at a provider that is ALREADY configured — its key if it has none (or a
6078
+ * rejected one), its model list, and the agent settings.
6079
+ */
6080
+ async function pointAgentAtProvider(row, host) {
6081
+ const ps = await import("./coding-provider-setup.mjs");
6082
+ const am = await import("./agent-model-command.mjs");
6083
+ const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6084
+ const url = ps.ensureV1Root(String(row.url ?? ""));
6085
+ let key = (await withPendingStatus(`reading the key for ${host}`, () => fetchCodingKey(String(row.id ?? "")))).key;
6086
+ if (!key) {
6087
+ console.log(GUTTER + dim(`no API key is stored for ${host} yet.`));
6088
+ key = await askKeyForRow(row, host);
6089
+ if (!key) return;
6090
+ }
6091
+ const list = async (k) =>
6092
+ withPendingStatus(`asking ${host} for its models`, async () => {
6093
+ try {
6094
+ const res = await proxyFetch(ps.modelsUrlFor(url), { headers: am.agentListHeaders(k), signal: AbortSignal.timeout(12_000) });
6095
+ return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
6096
+ } catch (err) {
6097
+ return { err };
6098
+ }
6099
+ });
6100
+ let probe = await list(key);
6101
+ if (probe.status === 401 || probe.status === 403) {
6102
+ console.log(yellow(` ${host} rejected the stored key (HTTP ${probe.status}) — enter the right one.`));
6103
+ key = await askKeyForRow(row, host);
6104
+ if (!key) return;
6105
+ probe = await list(key);
6106
+ }
6107
+ let models = [];
6108
+ if (probe.body) {
6109
+ models = am.agentModelsFromProvider(probe.body).map((m) => m.model);
6110
+ } else {
6111
+ for (const l of ps.describeProbeFailure(probe).lines) console.log(GUTTER + yellow(l));
6112
+ console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
6113
+ }
6114
+ const model = models.length ? await chooseFromModelList(models, "Which model talks?", 0) : (await ask(" model id: ")).trim();
6115
+ if (!model) {
6116
+ console.log(dim(" (cancelled — nothing was saved)\n"));
6117
+ return;
6118
+ }
6119
+ await saveAgentModel(url, model, host);
6120
+ }
6121
+
6122
+ /**
6123
+ * #41: the agent model on an OLLAMA server — this machine's, a LAN box, or the Ollama cloud.
6124
+ *
6125
+ * Local and LAN Ollama take no key: the agent URL and model are saved, and the API sends its
6126
+ * placeholder, which Ollama ignores. The CLOUD (https://ollama.com) needs one, and the API finds an
6127
+ * agent's key through the provider row whose URL matches — so a cloud server reuses the row that is
6128
+ * already there (a kimi-k3 coder on ollama.com has one), or gets one, with the key bound to it.
6129
+ */
6130
+ async function addOllamaAgent() {
6131
+ const am = await import("./agent-model-command.mjs");
6132
+ const ps = await import("./coding-provider-setup.mjs");
6133
+ const md = await import("./model-doctor.mjs");
6134
+ const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6135
+ const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6136
+ if (!settings) {
6137
+ console.log(red(" couldn't reach the API to read your current settings.\n"));
6138
+ return;
6139
+ }
6140
+ const dflt = am.ollamaAgentDefault(settings);
6141
+ console.log(dim(" An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."));
6142
+ console.log(dim(` Enter uses ${dflt} · Esc cancels\n`));
6143
+
6144
+ let target = null;
6145
+ for (let attempt = 0; attempt < 3 && !target; attempt++) {
6146
+ const typed = (await ask(" Ollama server: ")).trim();
6147
+ const r = am.ollamaAgentUrl(typed || dflt);
6148
+ if (!r.ok) {
6149
+ console.log(GUTTER + dim(`"${typed}" is ${r.why} — try host:port, e.g. 127.0.0.1:11434`));
6150
+ continue;
6151
+ }
6152
+ target = r;
6153
+ }
6154
+ if (!target) {
6155
+ console.log(dim(" (nothing saved)\n"));
6156
+ return;
6157
+ }
6158
+
6159
+ // THE CLOUD NEEDS A KEY, and the key has to belong to a provider row with this URL.
6160
+ const existing = ps.existingBackendRows(settings);
6161
+ let row = target.cloud
6162
+ ? existing.find((b) => ps.ensureV1Root(String(b.url ?? "")).toLowerCase() === target.agentUrl.toLowerCase()) ?? null
6163
+ : null;
6164
+ let key = "";
6165
+ let newRow = null;
6166
+ if (target.cloud) {
6167
+ if (row) key = (await withPendingStatus(`reading the key for ${target.host}`, () => fetchCodingKey(String(row.id ?? "")))).key;
6168
+ if (!key) {
6169
+ if (!row) {
6170
+ if (existing.length >= ps.MAX_BACKENDS) {
6171
+ console.log(yellow(` ${ps.MAX_BACKENDS} providers is the limit — remove one in web Settings → Agent first.\n`));
6172
+ return;
6173
+ }
6174
+ newRow = { id: ps.mintProviderId(existing.map((b) => String(b.id ?? ""))), kind: "ollama", enabled: true, url: target.agentUrl, defaultModel: "", models: [] };
6175
+ }
6176
+ console.log(GUTTER + dim(`${target.host} is the Ollama cloud — it needs an API key.`));
6177
+ console.log(GUTTER + dim(SECRET_KEY_HINT));
6178
+ key = (await askSecret(` API key for ${target.host}: `)).trim();
6179
+ if (!key) {
6180
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6181
+ return;
6182
+ }
6183
+ }
6184
+ }
6185
+
6186
+ // ITS MODELS, LIVE — with the key when it is the cloud.
6187
+ const probe = await withPendingStatus(`asking ${target.host} for its models`, async () => {
6188
+ try {
6189
+ const res = await proxyFetch(target.tagsUrl, { headers: am.agentListHeaders(key), signal: AbortSignal.timeout(12_000) });
6190
+ return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
6191
+ } catch (err) {
6192
+ return { err: err?.name === "TimeoutError" ? "no response in 12s" : (err?.message ?? String(err)) };
6193
+ }
6194
+ });
6195
+ if (!probe.body) {
6196
+ const why = probe.status ? `HTTP ${probe.status}` : probe.err;
6197
+ console.log(red(` couldn't list the models on ${target.host} — ${why}.`));
6198
+ if (probe.status === 401 || probe.status === 403) console.log(GUTTER + dim("the key was rejected — nothing was saved.\n"));
6199
+ else console.log(GUTTER + dim("check the server is running and reachable (and /proxy if this machine needs one) — nothing was saved.\n"));
6200
+ return;
6201
+ }
6202
+ const models = am.parseOllamaTags(probe.body);
6203
+ if (!models.length) {
6204
+ console.log(yellow(` ${target.host} answered but lists no models — pull one there first (ollama pull …).\n`));
6205
+ return;
6206
+ }
6207
+ const rows = am.agentModelRows(models, am.currentAgentModel(settings));
6208
+ console.log(dim(` ${models.length} on ${target.root} (${PICK_HINT}):`));
6209
+ const idx = await pickFromList(
6210
+ rows.map((r) => r.model + (r.embedder ? dim(" · embedding model, not for chat") : "") + (r.current ? accent(" (current)") : "")),
6211
+ Math.max(0, rows.findIndex((r) => r.current))
6212
+ );
6213
+ if (idx < 0) {
6214
+ console.log(dim(" (cancelled — nothing was saved)\n"));
6215
+ return;
6216
+ }
6217
+ const model = rows[idx].model;
6218
+
6219
+ // SAVE: the cloud's row and key first (the agent's key is found through them), then the agent.
6220
+ if (newRow) {
6221
+ const ok = await withPendingStatus("saving the Ollama cloud provider", () =>
6222
+ md.saveWorkerSettings(apiBase, workerKey, { agentCodingBackends: [...existing, { ...newRow, defaultModel: model }] })
6223
+ );
6224
+ if (!ok) {
6225
+ console.log(red(" couldn't save the provider — nothing changed.\n"));
6226
+ return;
6227
+ }
6228
+ row = newRow;
6229
+ console.log(GUTTER + dim(`${target.host} is also offered in /model now.`));
6230
+ }
6231
+ if (target.cloud && key && !(await fetchCodingKey(String(row?.id ?? ""))).key) {
6232
+ const id = await withPendingStatus("saving the key", () => addCodingKey("Ollama cloud", key, String(row?.id ?? "")));
6233
+ if (!id) {
6234
+ console.log(red(" couldn't save the key — the agent was not changed.\n"));
6235
+ return;
6236
+ }
6237
+ }
6238
+ await saveAgentModel(target.agentUrl, model, target.host);
6239
+ }
6240
+
6001
6241
  /**
6002
6242
  * `/agent-model` — switch the model that TALKS, from what the Ollama box actually has (#169).
6003
6243
  *
@@ -6029,7 +6269,23 @@ async function chooseAgentModel() {
6029
6269
  let models = [];
6030
6270
  let failure = null;
6031
6271
  let proxied = false;
6032
- if (!url) {
6272
+ // THE KEY, WHEN THIS PROVIDER NEEDS ONE (#40). The list used to be fetched with no Authorization
6273
+ // at all, so api.openai.com answered 401 however right the stored key was. Asked for the key
6274
+ // bound to THIS provider row — the same one the API will send with each turn.
6275
+ const needsKey = am.agentSourceNeedsKey(source);
6276
+ const listHost = url ? hostOf(url) : "";
6277
+ let listKey = "";
6278
+ let keyProblem = false;
6279
+ if (needsKey) {
6280
+ listKey = (await withPendingStatus(`reading the key for ${listHost}`, () => fetchCodingKey(String(source.row?.id ?? "")))).key;
6281
+ if (!listKey) {
6282
+ keyProblem = true;
6283
+ failure = `no API key is stored for ${listHost}`;
6284
+ }
6285
+ }
6286
+ if (failure) {
6287
+ // already decided — no request without the key it needs
6288
+ } else if (!url) {
6033
6289
  failure = "no agent model URL is configured for this machine";
6034
6290
  } else {
6035
6291
  try {
@@ -6041,9 +6297,10 @@ async function chooseAgentModel() {
6041
6297
  // terminal that ignored the command. The host is named in the label because on a slow
6042
6298
  // one that IS the useful information while you wait.
6043
6299
  const r = await withPendingStatus(`asking ${hostOf(url)} for its models`, () =>
6044
- proxyFetch(url, { signal: AbortSignal.timeout(10000) })
6300
+ proxyFetch(url, { headers: am.agentListHeaders(listKey), signal: AbortSignal.timeout(10000) })
6045
6301
  );
6046
6302
  if (!r.ok) failure = `HTTP ${r.status}`;
6303
+ if (!r.ok && needsKey && (r.status === 401 || r.status === 403)) keyProblem = true;
6047
6304
  else models = am.agentModelsFromBody(source.dialect, await r.json().catch(() => null));
6048
6305
  // A 200 with nothing in it is not a working list. Saying "the box answered but listed no
6049
6306
  // models" is a different diagnosis from "unreachable", and the user can act on it.
@@ -6061,11 +6318,18 @@ async function chooseAgentModel() {
6061
6318
  // printed the diagnosis and stopped, leaving no way forward from the place the problem was
6062
6319
  // found. /model had the same dead end before #223, and this is the same remedy: offer the
6063
6320
  // way out from where the reader already is.
6321
+ if (keyProblem) console.log(GUTTER + dim(`${listHost} needs an API key — enter one below to list its models.`));
6322
+ // By ACTION, not by position: the key entry is only there when a key is the problem (#40), and
6323
+ // the Ollama entry is new (#41) — indices would mean different things on different days.
6324
+ const options = am.agentRecoveryOptions({ keyProblem, host: listHost });
6064
6325
  const go = await pickFromList(
6065
- [dim("leave it"), "add an OpenAI-compatible provider for the agent…"],
6066
- 1
6326
+ options.map((o, i) => (i === 0 ? dim(o.label) : o.label)),
6327
+ keyProblem ? 1 : 0
6067
6328
  );
6068
- if (go === 1) await addCodingProvider("agent");
6329
+ const action = go >= 0 ? options[go].action : "leave";
6330
+ if (action === "key") await enterAgentProviderKey(source.row, listHost);
6331
+ else if (action === "ollama") await addOllamaAgent();
6332
+ else if (action === "provider") await addCodingProvider("agent");
6069
6333
  else console.log("");
6070
6334
  return;
6071
6335
  }
@@ -6101,17 +6365,23 @@ async function chooseAgentModel() {
6101
6365
  // because "which model on the box I am pointed at" and "I want a different provider" are
6102
6366
  // different questions, and only the first is answered by the rows above.
6103
6367
  labels.push("add an OpenAI-compatible provider for the agent…");
6368
+ // #41, appended AFTER the provider entry so no existing index moves.
6369
+ labels.push("use an Ollama server for the agent…");
6104
6370
  const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
6105
6371
  if (idx < 0) {
6106
6372
  console.log(dim(" (cancelled)\n"));
6107
6373
  return;
6108
6374
  }
6109
- // labels is now ONE LONGER than rows. Reading rows[idx] for the appended entry would
6110
- // dereference undefined and take the picker down, so it is answered before rows is touched.
6375
+ // labels is now TWO LONGER than rows. Reading rows[idx] for an appended entry would
6376
+ // dereference undefined and take the picker down, so they are answered before rows is touched.
6111
6377
  if (idx === rows.length) {
6112
6378
  await addCodingProvider("agent");
6113
6379
  return;
6114
6380
  }
6381
+ if (idx === rows.length + 1) {
6382
+ await addOllamaAgent();
6383
+ return;
6384
+ }
6115
6385
  const chosen = rows[idx].model;
6116
6386
  if (chosen === current) {
6117
6387
  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.698",
3
+ "version": "1.0.700",
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",