@tiens.nguyen/gu-cli 1.0.697 → 1.0.699

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,94 @@ 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
+ }
package/gu-repl.mjs CHANGED
@@ -5459,6 +5459,14 @@ async function addCodingProvider(roleName = "coding") {
5459
5459
  // ALREADY CONFIGURED is its own answer. Saving a second row for the same URL would give the
5460
5460
  // picker two rows that are identical in everything a person can see.
5461
5461
  const clash = existing.find((b) => ps.ensureV1Root(String(b.url ?? "")) === r.url);
5462
+ if (clash && roleName === "agent") {
5463
+ // FOR THE AGENT, "already configured" IS THE ANSWER, NOT A REFUSAL (#40). The provider and
5464
+ // its key may well be there already; what is missing is the agent pointing at it. Sending the
5465
+ // user to /model — the CODING picker — left no way to finish from here.
5466
+ console.log(GUTTER + dim(`${r.host} is already configured — using it for the agent.`));
5467
+ await pointAgentAtProvider(clash, r.host);
5468
+ return;
5469
+ }
5462
5470
  if (clash) {
5463
5471
  console.log(GUTTER + dim(`${r.host} is already configured here — pick its model in /model.`));
5464
5472
  continue;
@@ -5998,6 +6006,212 @@ async function chooseEmbedModel() {
5998
6006
  console.log(dim(` Make sure it is pulled there: ollama pull ${chosen}\n`));
5999
6007
  }
6000
6008
 
6009
+ /** Save the agent model on this machine: the URL the API sends turns to, and the model name. */
6010
+ async function saveAgentModel(agentUrl, model, host) {
6011
+ const md = await import("./model-doctor.mjs");
6012
+ const ok = await withPendingStatus("pointing the agent model at it", () =>
6013
+ md.saveWorkerSettings(apiBase, workerKey, { agentModelUrl: agentUrl, agentModelName: model, agentModel: model })
6014
+ );
6015
+ if (!ok) {
6016
+ console.log(red(` couldn't save the agent model — nothing changed.`));
6017
+ console.log(GUTTER + dim("if this machine's local API is old, restart it with `gu api restart`.\n"));
6018
+ return false;
6019
+ }
6020
+ console.log(accent(` ✓ agent model → ${model}`) + dim(` · ${host}`));
6021
+ console.log(dim(" takes effect on your next question.\n"));
6022
+ return true;
6023
+ }
6024
+
6025
+ /** Ask for a key and store it BOUND to this provider row. Returns the secret, or "" if cancelled. */
6026
+ async function askKeyForRow(row, host) {
6027
+ console.log("\n" + GUTTER + dim('A name just tells keys apart — "work", "personal".'));
6028
+ const name = (await ask(" key name: ")).trim();
6029
+ console.log(GUTTER + dim(SECRET_KEY_HINT));
6030
+ const secret = (await askSecret(` API key for ${host}${name ? ` (${name})` : ""}: `)).trim();
6031
+ if (!secret) {
6032
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6033
+ return "";
6034
+ }
6035
+ const id = await withPendingStatus("saving the key", () => addCodingKey(name, secret, String(row?.id ?? "")));
6036
+ if (!id) {
6037
+ console.log(red(" couldn't save the key — nothing changed.\n"));
6038
+ return "";
6039
+ }
6040
+ return secret;
6041
+ }
6042
+
6043
+ /** #40: the key was the problem — enter it, then list again. */
6044
+ async function enterAgentProviderKey(row, host) {
6045
+ if (!row) return;
6046
+ if (!(await askKeyForRow(row, host))) return;
6047
+ await chooseAgentModel();
6048
+ }
6049
+
6050
+ /**
6051
+ * #40: point the agent at a provider that is ALREADY configured — its key if it has none (or a
6052
+ * rejected one), its model list, and the agent settings.
6053
+ */
6054
+ async function pointAgentAtProvider(row, host) {
6055
+ const ps = await import("./coding-provider-setup.mjs");
6056
+ const am = await import("./agent-model-command.mjs");
6057
+ const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6058
+ const url = ps.ensureV1Root(String(row.url ?? ""));
6059
+ let key = (await withPendingStatus(`reading the key for ${host}`, () => fetchCodingKey(String(row.id ?? "")))).key;
6060
+ if (!key) {
6061
+ console.log(GUTTER + dim(`no API key is stored for ${host} yet.`));
6062
+ key = await askKeyForRow(row, host);
6063
+ if (!key) return;
6064
+ }
6065
+ const list = async (k) =>
6066
+ withPendingStatus(`asking ${host} for its models`, async () => {
6067
+ try {
6068
+ const res = await proxyFetch(ps.modelsUrlFor(url), { headers: am.agentListHeaders(k), signal: AbortSignal.timeout(12_000) });
6069
+ return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
6070
+ } catch (err) {
6071
+ return { err };
6072
+ }
6073
+ });
6074
+ let probe = await list(key);
6075
+ if (probe.status === 401 || probe.status === 403) {
6076
+ console.log(yellow(` ${host} rejected the stored key (HTTP ${probe.status}) — enter the right one.`));
6077
+ key = await askKeyForRow(row, host);
6078
+ if (!key) return;
6079
+ probe = await list(key);
6080
+ }
6081
+ let models = [];
6082
+ if (probe.body) {
6083
+ models = am.agentModelsFromProvider(probe.body).map((m) => m.model);
6084
+ } else {
6085
+ for (const l of ps.describeProbeFailure(probe).lines) console.log(GUTTER + yellow(l));
6086
+ console.log(GUTTER + dim("carrying on — you can type the model id yourself."));
6087
+ }
6088
+ const model = models.length ? await chooseFromModelList(models, "Which model talks?", 0) : (await ask(" model id: ")).trim();
6089
+ if (!model) {
6090
+ console.log(dim(" (cancelled — nothing was saved)\n"));
6091
+ return;
6092
+ }
6093
+ await saveAgentModel(url, model, host);
6094
+ }
6095
+
6096
+ /**
6097
+ * #41: the agent model on an OLLAMA server — this machine's, a LAN box, or the Ollama cloud.
6098
+ *
6099
+ * Local and LAN Ollama take no key: the agent URL and model are saved, and the API sends its
6100
+ * placeholder, which Ollama ignores. The CLOUD (https://ollama.com) needs one, and the API finds an
6101
+ * agent's key through the provider row whose URL matches — so a cloud server reuses the row that is
6102
+ * already there (a kimi-k3 coder on ollama.com has one), or gets one, with the key bound to it.
6103
+ */
6104
+ async function addOllamaAgent() {
6105
+ const am = await import("./agent-model-command.mjs");
6106
+ const ps = await import("./coding-provider-setup.mjs");
6107
+ const md = await import("./model-doctor.mjs");
6108
+ const { proxyFetch } = await import("./proxy-dispatcher.mjs");
6109
+ const settings = await withPendingStatus("reading your model settings", fetchWorkerSettings);
6110
+ if (!settings) {
6111
+ console.log(red(" couldn't reach the API to read your current settings.\n"));
6112
+ return;
6113
+ }
6114
+ const dflt = am.ollamaAgentDefault(settings);
6115
+ console.log(dim(" An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."));
6116
+ console.log(dim(` Enter uses ${dflt} · Esc cancels\n`));
6117
+
6118
+ let target = null;
6119
+ for (let attempt = 0; attempt < 3 && !target; attempt++) {
6120
+ const typed = (await ask(" Ollama server: ")).trim();
6121
+ const r = am.ollamaAgentUrl(typed || dflt);
6122
+ if (!r.ok) {
6123
+ console.log(GUTTER + dim(`"${typed}" is ${r.why} — try host:port, e.g. 127.0.0.1:11434`));
6124
+ continue;
6125
+ }
6126
+ target = r;
6127
+ }
6128
+ if (!target) {
6129
+ console.log(dim(" (nothing saved)\n"));
6130
+ return;
6131
+ }
6132
+
6133
+ // THE CLOUD NEEDS A KEY, and the key has to belong to a provider row with this URL.
6134
+ const existing = ps.existingBackendRows(settings);
6135
+ let row = target.cloud
6136
+ ? existing.find((b) => ps.ensureV1Root(String(b.url ?? "")).toLowerCase() === target.agentUrl.toLowerCase()) ?? null
6137
+ : null;
6138
+ let key = "";
6139
+ let newRow = null;
6140
+ if (target.cloud) {
6141
+ if (row) key = (await withPendingStatus(`reading the key for ${target.host}`, () => fetchCodingKey(String(row.id ?? "")))).key;
6142
+ if (!key) {
6143
+ if (!row) {
6144
+ if (existing.length >= ps.MAX_BACKENDS) {
6145
+ console.log(yellow(` ${ps.MAX_BACKENDS} providers is the limit — remove one in web Settings → Agent first.\n`));
6146
+ return;
6147
+ }
6148
+ newRow = { id: ps.mintProviderId(existing.map((b) => String(b.id ?? ""))), kind: "ollama", enabled: true, url: target.agentUrl, defaultModel: "", models: [] };
6149
+ }
6150
+ console.log(GUTTER + dim(`${target.host} is the Ollama cloud — it needs an API key.`));
6151
+ console.log(GUTTER + dim(SECRET_KEY_HINT));
6152
+ key = (await askSecret(` API key for ${target.host}: `)).trim();
6153
+ if (!key) {
6154
+ console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
6155
+ return;
6156
+ }
6157
+ }
6158
+ }
6159
+
6160
+ // ITS MODELS, LIVE — with the key when it is the cloud.
6161
+ const probe = await withPendingStatus(`asking ${target.host} for its models`, async () => {
6162
+ try {
6163
+ const res = await proxyFetch(target.tagsUrl, { headers: am.agentListHeaders(key), signal: AbortSignal.timeout(12_000) });
6164
+ return res.ok ? { body: await res.json().catch(() => null) } : { status: res.status };
6165
+ } catch (err) {
6166
+ return { err: err?.name === "TimeoutError" ? "no response in 12s" : (err?.message ?? String(err)) };
6167
+ }
6168
+ });
6169
+ if (!probe.body) {
6170
+ const why = probe.status ? `HTTP ${probe.status}` : probe.err;
6171
+ console.log(red(` couldn't list the models on ${target.host} — ${why}.`));
6172
+ if (probe.status === 401 || probe.status === 403) console.log(GUTTER + dim("the key was rejected — nothing was saved.\n"));
6173
+ else console.log(GUTTER + dim("check the server is running and reachable (and /proxy if this machine needs one) — nothing was saved.\n"));
6174
+ return;
6175
+ }
6176
+ const models = am.parseOllamaTags(probe.body);
6177
+ if (!models.length) {
6178
+ console.log(yellow(` ${target.host} answered but lists no models — pull one there first (ollama pull …).\n`));
6179
+ return;
6180
+ }
6181
+ const rows = am.agentModelRows(models, am.currentAgentModel(settings));
6182
+ console.log(dim(` ${models.length} on ${target.root} (${PICK_HINT}):`));
6183
+ const idx = await pickFromList(
6184
+ rows.map((r) => r.model + (r.embedder ? dim(" · embedding model, not for chat") : "") + (r.current ? accent(" (current)") : "")),
6185
+ Math.max(0, rows.findIndex((r) => r.current))
6186
+ );
6187
+ if (idx < 0) {
6188
+ console.log(dim(" (cancelled — nothing was saved)\n"));
6189
+ return;
6190
+ }
6191
+ const model = rows[idx].model;
6192
+
6193
+ // SAVE: the cloud's row and key first (the agent's key is found through them), then the agent.
6194
+ if (newRow) {
6195
+ const ok = await withPendingStatus("saving the Ollama cloud provider", () =>
6196
+ md.saveWorkerSettings(apiBase, workerKey, { agentCodingBackends: [...existing, { ...newRow, defaultModel: model }] })
6197
+ );
6198
+ if (!ok) {
6199
+ console.log(red(" couldn't save the provider — nothing changed.\n"));
6200
+ return;
6201
+ }
6202
+ row = newRow;
6203
+ console.log(GUTTER + dim(`${target.host} is also offered in /model now.`));
6204
+ }
6205
+ if (target.cloud && key && !(await fetchCodingKey(String(row?.id ?? ""))).key) {
6206
+ const id = await withPendingStatus("saving the key", () => addCodingKey("Ollama cloud", key, String(row?.id ?? "")));
6207
+ if (!id) {
6208
+ console.log(red(" couldn't save the key — the agent was not changed.\n"));
6209
+ return;
6210
+ }
6211
+ }
6212
+ await saveAgentModel(target.agentUrl, model, target.host);
6213
+ }
6214
+
6001
6215
  /**
6002
6216
  * `/agent-model` — switch the model that TALKS, from what the Ollama box actually has (#169).
6003
6217
  *
@@ -6029,7 +6243,23 @@ async function chooseAgentModel() {
6029
6243
  let models = [];
6030
6244
  let failure = null;
6031
6245
  let proxied = false;
6032
- if (!url) {
6246
+ // THE KEY, WHEN THIS PROVIDER NEEDS ONE (#40). The list used to be fetched with no Authorization
6247
+ // at all, so api.openai.com answered 401 however right the stored key was. Asked for the key
6248
+ // bound to THIS provider row — the same one the API will send with each turn.
6249
+ const needsKey = am.agentSourceNeedsKey(source);
6250
+ const listHost = url ? hostOf(url) : "";
6251
+ let listKey = "";
6252
+ let keyProblem = false;
6253
+ if (needsKey) {
6254
+ listKey = (await withPendingStatus(`reading the key for ${listHost}`, () => fetchCodingKey(String(source.row?.id ?? "")))).key;
6255
+ if (!listKey) {
6256
+ keyProblem = true;
6257
+ failure = `no API key is stored for ${listHost}`;
6258
+ }
6259
+ }
6260
+ if (failure) {
6261
+ // already decided — no request without the key it needs
6262
+ } else if (!url) {
6033
6263
  failure = "no agent model URL is configured for this machine";
6034
6264
  } else {
6035
6265
  try {
@@ -6041,9 +6271,10 @@ async function chooseAgentModel() {
6041
6271
  // terminal that ignored the command. The host is named in the label because on a slow
6042
6272
  // one that IS the useful information while you wait.
6043
6273
  const r = await withPendingStatus(`asking ${hostOf(url)} for its models`, () =>
6044
- proxyFetch(url, { signal: AbortSignal.timeout(10000) })
6274
+ proxyFetch(url, { headers: am.agentListHeaders(listKey), signal: AbortSignal.timeout(10000) })
6045
6275
  );
6046
6276
  if (!r.ok) failure = `HTTP ${r.status}`;
6277
+ if (!r.ok && needsKey && (r.status === 401 || r.status === 403)) keyProblem = true;
6047
6278
  else models = am.agentModelsFromBody(source.dialect, await r.json().catch(() => null));
6048
6279
  // A 200 with nothing in it is not a working list. Saying "the box answered but listed no
6049
6280
  // models" is a different diagnosis from "unreachable", and the user can act on it.
@@ -6061,11 +6292,18 @@ async function chooseAgentModel() {
6061
6292
  // printed the diagnosis and stopped, leaving no way forward from the place the problem was
6062
6293
  // found. /model had the same dead end before #223, and this is the same remedy: offer the
6063
6294
  // way out from where the reader already is.
6295
+ if (keyProblem) console.log(GUTTER + dim(`${listHost} needs an API key — enter one below to list its models.`));
6296
+ // By ACTION, not by position: the key entry is only there when a key is the problem (#40), and
6297
+ // the Ollama entry is new (#41) — indices would mean different things on different days.
6298
+ const options = am.agentRecoveryOptions({ keyProblem, host: listHost });
6064
6299
  const go = await pickFromList(
6065
- [dim("leave it"), "add an OpenAI-compatible provider for the agent…"],
6066
- 1
6300
+ options.map((o, i) => (i === 0 ? dim(o.label) : o.label)),
6301
+ keyProblem ? 1 : 0
6067
6302
  );
6068
- if (go === 1) await addCodingProvider("agent");
6303
+ const action = go >= 0 ? options[go].action : "leave";
6304
+ if (action === "key") await enterAgentProviderKey(source.row, listHost);
6305
+ else if (action === "ollama") await addOllamaAgent();
6306
+ else if (action === "provider") await addCodingProvider("agent");
6069
6307
  else console.log("");
6070
6308
  return;
6071
6309
  }
@@ -6101,17 +6339,23 @@ async function chooseAgentModel() {
6101
6339
  // because "which model on the box I am pointed at" and "I want a different provider" are
6102
6340
  // different questions, and only the first is answered by the rows above.
6103
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…");
6104
6344
  const idx = await pickFromList(labels, Math.max(0, rows.findIndex((r) => r.current)));
6105
6345
  if (idx < 0) {
6106
6346
  console.log(dim(" (cancelled)\n"));
6107
6347
  return;
6108
6348
  }
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.
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.
6111
6351
  if (idx === rows.length) {
6112
6352
  await addCodingProvider("agent");
6113
6353
  return;
6114
6354
  }
6355
+ if (idx === rows.length + 1) {
6356
+ await addOllamaAgent();
6357
+ return;
6358
+ }
6115
6359
  const chosen = rows[idx].model;
6116
6360
  if (chosen === current) {
6117
6361
  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.697",
3
+ "version": "1.0.699",
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",