@tiens.nguyen/gu-cli 1.0.734 → 1.0.736

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.
@@ -95,7 +95,46 @@ export function ollamaWhere(kind, url) {
95
95
  return (local ? "127.0.0.1" : host) + port;
96
96
  }
97
97
 
98
- /** The coder's name as shown: "ollama.com/kimi-k3" for an Ollama coder, the bare name otherwise. */
98
+ /**
99
+ * WHICH SERVER A CODER RUNS ON — for EVERY coder, not only Ollama ones (#263). Reported: "you
100
+ * should show the server before the model, correctly — later I can have another model on a
101
+ * different server". A bare "claude-opus-5" cannot say which provider (or which of two gateways)
102
+ * it goes to; "api.anthropic.com/claude-opus-5" can.
103
+ * · Ollama keeps ollamaWhere's rules (ollama.com, 127.0.0.1, a non-default port).
104
+ * · Anything else: its host, this machine as 127.0.0.1, and the port when it is not the
105
+ * scheme's default — two local servers on two ports are two servers.
106
+ */
107
+ export function coderWhere(kind, url) {
108
+ if (kind === "ollama") return ollamaWhere(kind, url);
109
+ let u;
110
+ try {
111
+ u = new URL(String(url ?? ""));
112
+ } catch {
113
+ return "";
114
+ }
115
+ const host = u.hostname.toLowerCase().replace(/\.$/, "").replace(/^\[|\]$/g, "");
116
+ if (!host) return "";
117
+ const local = host === "localhost" || host === "127.0.0.1" || host === "::1";
118
+ const port = u.port ? `:${u.port}` : ""; // URL() already drops :443 on https and :80 on http
119
+ return (local ? "127.0.0.1" : host) + port;
120
+ }
121
+
122
+ /**
123
+ * WHERE THE CODER IN USE RUNS — the host the bar and banner put in front of its name (#263).
124
+ * The folder's pick (a choice id, or a bare model name), else the account default. No list (an
125
+ * older API) → "", never a guess from raw payload fields (key-choice-regression.test.mjs).
126
+ * One rule for startup and for every path that changes the coder: computing it in only one of them
127
+ * is what left "ollama.com/" in front of a newly added Claude model.
128
+ */
129
+ export function whereOfActiveCoder(choices, sessionModel) {
130
+ const list = Array.isArray(choices) ? choices : [];
131
+ const pick = String(sessionModel ?? "");
132
+ const active =
133
+ (pick && list.find((c) => c.id === pick || c.model === pick)) || list.find((c) => c.isDefault) || null;
134
+ return active ? coderWhere(active.kind, active.url) : "";
135
+ }
136
+
137
+ /** The coder's name as shown: "<server>/<model>" ("api.anthropic.com/claude-opus-5"), bare when no server is known. */
99
138
  export function coderDisplayName(name, where) {
100
139
  const n = String(name ?? "").trim();
101
140
  return where && n ? `${where}/${n}` : n;
@@ -233,6 +233,24 @@ export function modelListRequest(url, key = "") {
233
233
  return { url: modelsUrlFor(url), headers: keyed ? { Authorization: `Bearer ${k}` } : {} };
234
234
  }
235
235
 
236
+ /**
237
+ * Why a provider's model list could not be shown, and what to type instead (#266). Reported:
238
+ * "did not list its models without a key — type the id … what id?". The line now says WHY
239
+ * (no key saved / key refused / unreachable) and WHAT to type: the model's exact name as that
240
+ * provider calls it, with the current one as the example.
241
+ * reason: "no-key" | "refused" | "failed"
242
+ */
243
+ export function describeListFailure({ host = "", reason = "failed", status = 0, keyName = "", example = "" } = {}) {
244
+ const what = `type the model's name exactly as ${host} calls it${example ? ` (e.g. ${example})` : ""}`;
245
+ if (reason === "no-key") {
246
+ return [`no API key is saved for ${host}, so it cannot list its models —`, `${what}, or add a key with ctrl+k first.`];
247
+ }
248
+ if (reason === "refused") {
249
+ return [`${host} refused the key${keyName ? ` "${keyName}"` : ""}${status ? ` (HTTP ${status})` : ""} when listing its models —`, `${what}, or fix the key with ctrl+k.`];
250
+ }
251
+ return [`couldn't list ${host}'s models${status ? ` (HTTP ${status})` : " (no answer)"} —`, `${what}.`];
252
+ }
253
+
236
254
  /**
237
255
  * The hosted providers offered by name when adding one (#261), before "another URL". Claude has
238
256
  * no embedding models (Anthropic points to Voyage AI), so it is not offered for the embedder.
@@ -1152,6 +1152,18 @@ def _anthropic_usage(u) -> "types.SimpleNamespace":
1152
1152
  cache_read_input_tokens=cr, cache_creation_input_tokens=cw)
1153
1153
 
1154
1154
 
1155
+ def _log_anthropic_usage(u) -> None:
1156
+ """One content-free line per Claude request with Anthropic's OWN cache figures (task #264).
1157
+ Without it the log could not say whether caching worked — the reported question — although
1158
+ every response carried the answer."""
1159
+ g = (lambda n: int(_usage_field(u, n) or 0))
1160
+ read, write, fresh = g("cache_read_input_tokens"), g("cache_creation_input_tokens"), g("input_tokens")
1161
+ total = read + write + fresh
1162
+ pct = f"{100 * read // total}%" if total else "—"
1163
+ _log(f"claude usage: prompt {total} = cache read {read} + cache write {write} + uncached {fresh} "
1164
+ f"({pct} read from cache) · output {g('output_tokens')}")
1165
+
1166
+
1155
1167
  class _AnthropicChatAdapter:
1156
1168
  """client.chat.completions.create() backed by Anthropic's native Messages API (task #262)."""
1157
1169
 
@@ -1176,6 +1188,7 @@ class _AnthropicChatAdapter:
1176
1188
  if kw.get("stream"):
1177
1189
  return self._stream(req)
1178
1190
  msg = self._sdk.messages.create(**req)
1191
+ _log_anthropic_usage(getattr(msg, "usage", None))
1179
1192
  import types
1180
1193
  text = "".join(getattr(b, "text", "") or "" for b in (msg.content or []) if getattr(b, "type", "") == "text")
1181
1194
  return types.SimpleNamespace(
@@ -1217,6 +1230,7 @@ class _AnthropicChatAdapter:
1217
1230
  usage["output_tokens"] = int(out)
1218
1231
  if stop_reason == "refusal":
1219
1232
  _log("claude: the model declined this request (stop_reason=refusal)")
1233
+ _log_anthropic_usage(usage)
1220
1234
  yield chunk(content="", finish=_ANTHROPIC_FINISH.get(stop_reason, "stop"))
1221
1235
  yield chunk(usage=_anthropic_usage(usage))
1222
1236
 
@@ -8243,12 +8257,18 @@ def run_agent_chat(cfg):
8243
8257
  # Variable text — the deploy target and the current date/time — lives in the
8244
8258
  # variable tail next to the task instead.
8245
8259
  tool_hint = (
8260
+ # ONE batching rule, the system prompt's (task #265): this line used to say "ONE code block
8261
+ # that calls a SINGLE tool", contradicting the scaffold's BATCH rule from nearer the task —
8262
+ # measured as a 102-step turn in which every file read was its own full round trip.
8246
8263
  f"Solve the TASK step by step (up to {max_steps} steps). At EACH step write a "
8247
- "brief Thought, then ONE code block that calls a SINGLE tool. You will SEE that "
8248
- "tool's result (Observation) before the next step — use it to decide what to do "
8249
- "next (e.g. web_search to find a URL, THEN fetch_url to read it). When you have "
8250
- "enough to answer, call final_answer(<your answer>) in a code block. Prefer "
8251
- "FEWER steps, and never repeat a tool call that already succeeded.\n\n"
8264
+ "brief Thought, then ONE code block. BATCH INDEPENDENT LOOKUPS into that block — "
8265
+ "several reads, greps or listings whose arguments you already know, printing each "
8266
+ "result under a header (up to about 8). Make ONE CHANGE per block (an edit, a file "
8267
+ "write or a command): if a line fails, the lines after it never run. Take a separate "
8268
+ "step only when a call depends on a result you have not seen yet (e.g. web_search to "
8269
+ "find a URL, THEN fetch_url to read it). You SEE the Observation before the next step. "
8270
+ "When you have enough to answer, call final_answer(<your answer>) in a code block. "
8271
+ "Prefer FEWER steps, and never repeat a tool call that already succeeded.\n\n"
8252
8272
  "{{TOOL_LIST}}\n" # filled in below, once from the REAL registered tool objects
8253
8273
  + _rag_tool_block + _ws_tool_block + _auto_test_block +
8254
8274
  "\n"
package/gu-repl.mjs CHANGED
@@ -120,7 +120,7 @@ import {
120
120
  } from "./terminal-layout.mjs";
121
121
  import { completedFlush } from "./completed-flush.mjs";
122
122
  import { codingBannerLabel, isOllamaCloudUrl, SECRET_KEY_HINT } from "./coding-key-prompt.mjs";
123
- import { codingChoiceTags, coderDisplayName, ollamaWhere } from "./coding-choice-tags.mjs";
123
+ import { codingChoiceTags, coderDisplayName, coderWhere, whereOfActiveCoder } from "./coding-choice-tags.mjs";
124
124
  import {
125
125
  isDoubleClick,
126
126
  clickRun,
@@ -5242,6 +5242,7 @@ async function reconfigureOllamaServer() {
5242
5242
  // Persist the CLEARING too. The stored override names a model on the OLD box; leaving it on
5243
5243
  // disk would resurrect it at the next launch, against a server that has never heard of it.
5244
5244
  await saveSessionCoding(resolve(process.cwd()));
5245
+ await syncActiveCoderWhere(); // the bar now names the account default — with ITS host (#263)
5245
5246
  // The GLOBAL output-token total is keyed to the coding backend (task #125), and the backend
5246
5247
  // just moved — re-fetch it for the new one, exactly as /model does after a pick, or the
5247
5248
  // footer keeps counting against the box that is no longer in use.
@@ -5355,7 +5356,7 @@ async function chooseCodingModelFrom(ps, all, question, current) {
5355
5356
  let list = coders;
5356
5357
  let showingAll = filtered === 0;
5357
5358
  for (;;) {
5358
- const rows = [...list, "type a model id…"];
5359
+ const rows = [...list, "type a model name…"];
5359
5360
  // The row that lifts the filter, offered only while it is on and only when it hides something.
5360
5361
  if (!showingAll) rows.push(`show all ${all.length} models…`);
5361
5362
  // Opens on the CURRENT model where there is one — a long list that opens at the top tells the
@@ -5368,7 +5369,7 @@ async function chooseCodingModelFrom(ps, all, question, current) {
5368
5369
  const idx = await pickFromList(rows, at >= 0 ? at : 0);
5369
5370
  if (idx < 0) return null;
5370
5371
  if (idx === list.length) {
5371
- const typed = (await ask(" model id: ")).trim();
5372
+ const typed = (await ask(" model name: ")).trim();
5372
5373
  return typed || null;
5373
5374
  }
5374
5375
  if (idx === list.length + 1) {
@@ -5394,13 +5395,13 @@ async function chooseCodingModelFrom(ps, all, question, current) {
5394
5395
  * unlisted one may be exactly right.
5395
5396
  */
5396
5397
  async function chooseFromModelList(all, question, current = 0) {
5397
- const rows = [...all, "type a model id…"];
5398
+ const rows = [...all, "type a model name…"];
5398
5399
  const at = typeof current === "string" ? all.indexOf(current) : Number(current) || 0;
5399
5400
  console.log(dim(` ${question} (${PICK_HINT})`));
5400
5401
  const idx = await pickFromList(rows, at >= 0 ? at : 0);
5401
5402
  if (idx < 0) return null;
5402
5403
  if (idx === all.length) {
5403
- const typed = (await ask(" model id: ")).trim();
5404
+ const typed = (await ask(" model name: ")).trim();
5404
5405
  return typed || null;
5405
5406
  }
5406
5407
  return all[idx];
@@ -5458,15 +5459,22 @@ async function changeProviderModel(ps, rows, p) {
5458
5459
  const md = await import("./model-doctor.mjs");
5459
5460
  const { proxyFetch } = await import("./proxy-dispatcher.mjs");
5460
5461
 
5461
- // PROBED WITHOUT A KEY, DELIBERATELY. The terminal does not hold this provider's secret, and
5462
- // the one key it could fetch is the ACCOUNT DEFAULT — which may belong to a different provider
5463
- // entirely. Sending it here to be helpful would be the exact leak this task spent a phase
5464
- // preventing. Many servers (Ollama, vLLM, LM Studio) list models unauthenticated; a hosted one
5465
- // will refuse, and then the id is typed, which always works.
5462
+ // LISTED WITH THIS PROVIDER'S OWN KEY (#266). This used to probe with NO key, on purpose: the
5463
+ // only key the terminal could fetch was the ACCOUNT DEFAULT, which may belong to another provider.
5464
+ // Since #245 a key belongs to its server, so the key used is the one bound to THIS row — the same
5465
+ // check addServer makes — and never another provider's. Keyed providers (Claude, OpenAI, Kimi,
5466
+ // ollama.com) can finally list their models here instead of asking for an id.
5467
+ const sk = await import("./server-keys.mjs");
5468
+ const own = await withPendingStatus(`reading the key for ${p.host}`, async () => {
5469
+ const keys = await fetchServerKeys(p.id);
5470
+ const inUse = sk.keyInUse(keys ?? [], p.id, sessionCodingKeyId);
5471
+ if (!inUse || inUse.backendId !== p.id) return { key: "", name: "" };
5472
+ const r = await fetchCodingKey(p.id);
5473
+ return r.keyId && r.keyId !== inUse.id ? { key: "", name: "" } : { key: r.key, name: String(inUse.name ?? "") };
5474
+ });
5466
5475
  const probe = await withPendingStatus(`asking ${p.host} what it has`, async () => {
5467
5476
  try {
5468
- // Keyless, as above — but in the list's own dialect (the Claude API's headers, #261).
5469
- const lr = ps.modelListRequest(p.url);
5477
+ const lr = ps.modelListRequest(p.url, own.key); // the list's own dialect too (Claude's headers, #261)
5470
5478
  const res = await proxyFetch(lr.url, { headers: lr.headers, signal: AbortSignal.timeout(10_000) });
5471
5479
  if (!res.ok) return { status: res.status };
5472
5480
  return { body: await res.json().catch(() => null) };
@@ -5486,7 +5494,11 @@ async function changeProviderModel(ps, rows, p) {
5486
5494
  const rn = ps.retiredNote(parsed.retired, p.host);
5487
5495
  if (rn) console.log(GUTTER + dim(rn));
5488
5496
  } else {
5489
- console.log(GUTTER + dim(`${p.host} did not list its models without a key — type the id.`));
5497
+ const refused = probe.status === 401 || probe.status === 403;
5498
+ const reason = refused ? (own.key ? "refused" : "no-key") : "failed";
5499
+ for (const l of ps.describeListFailure({ host: p.host, reason, status: probe.status, keyName: own.name, example: p.model })) {
5500
+ console.log(GUTTER + dim(l));
5501
+ }
5490
5502
  }
5491
5503
 
5492
5504
  let model = "";
@@ -5498,7 +5510,7 @@ async function changeProviderModel(ps, rows, p) {
5498
5510
  }
5499
5511
  } else {
5500
5512
  console.log(GUTTER + dim(`Enter keeps ${p.model || "it unset"}`));
5501
- model = (await ask(" model id: ")).trim();
5513
+ model = (await ask(" model name: ")).trim();
5502
5514
  }
5503
5515
  if (!model) {
5504
5516
  console.log(GUTTER + dim("(nothing changed)\n"));
@@ -5533,6 +5545,7 @@ async function changeProviderModel(ps, rows, p) {
5533
5545
  sessionCodingLabel = "";
5534
5546
  sessionCodingUrl = "";
5535
5547
  await saveSessionCoding(resolve(process.cwd()));
5548
+ await syncActiveCoderWhere();
5536
5549
  console.log(GUTTER + dim("this folder's pick named the old model — cleared it."));
5537
5550
  }
5538
5551
  console.log(accent(` ✓ ${p.host} → ${model}`) + dim(" · pick it in /model\n"));
@@ -5625,6 +5638,7 @@ async function removeCodingProvider(ps, rows, p) {
5625
5638
  sessionCodingLabel = "";
5626
5639
  sessionCodingUrl = "";
5627
5640
  await saveSessionCoding(resolve(process.cwd()));
5641
+ await syncActiveCoderWhere();
5628
5642
  console.log(GUTTER + dim("this folder was using it — cleared the pick.\n"));
5629
5643
  }
5630
5644
  console.log(
@@ -5834,7 +5848,7 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
5834
5848
  return;
5835
5849
  }
5836
5850
  } else {
5837
- model = String((await ask(" model id: ")) ?? "").trim();
5851
+ model = String((await ask(" model name: ")) ?? "").trim();
5838
5852
  }
5839
5853
  if (!model) {
5840
5854
  console.log(GUTTER + dim("(cancelled — nothing was saved)\n"));
@@ -5979,6 +5993,9 @@ async function useCoderInThisFolder({ ps, rows, beforeChoices, url, host, locali
5979
5993
  sessionCodingModel = offered.id;
5980
5994
  sessionCodingLabel = model;
5981
5995
  sessionCodingUrl = url;
5996
+ // …and the host the bar puts in front of it (#263): left alone, the PREVIOUS coder's
5997
+ // "ollama.com/" stayed in front of a newly added Claude model until the next /model pick.
5998
+ activeCoderWhere = whereOfActiveCoder(after?.codingChoices, sessionCodingModel);
5982
5999
  // The folder follows the SERVER's key (task #245) — its default, which a first key already is.
5983
6000
  sessionCodingKeyId = "";
5984
6001
  sessionApiKeyTail = key ? key.slice(-4) : "";
@@ -6072,9 +6089,9 @@ async function chooseModel() {
6072
6089
  // OpenAI-compatible providers are both "OpenAI-compatible", so the kind names neither.
6073
6090
  const { tags, show: showTag } = codingChoiceTags(choices);
6074
6091
  const labels = choices.map((c, i) => {
6075
- // An Ollama row says WHERE it runs in front of the name ("ollama.com/kimi-k3"), so the kind tag
6076
- // beside it would only repeat "Ollama". Other rows keep the tag rule.
6077
- const where = ollamaWhere(c.kind, c.url);
6092
+ // Every row says WHERE it runs in front of the name ("ollama.com/kimi-k3",
6093
+ // "api.anthropic.com/claude-opus-5" — #263), so the kind tag is only for a row with no server.
6094
+ const where = coderWhere(c.kind, c.url);
6078
6095
  const tag = showTag && !where ? ` ${dim(`· ${tags[i]}`)}` : "";
6079
6096
  return `${coderDisplayName(c.model, where)}${tag}${c.id === def ? " (default)" : ""}`;
6080
6097
  });
@@ -6119,10 +6136,12 @@ async function chooseModel() {
6119
6136
  // Task #125: remember the chosen backend's coding URL so the GLOBAL output-token total is
6120
6137
  // keyed to it. Default pick → "" so activeCodingUrl() falls back to defaultCodingUrl.
6121
6138
  sessionCodingUrl = chosen.id === def ? "" : (chosen.url ?? "");
6139
+ // The server goes WITH the model, before any await (#263): set after the save below, the bar
6140
+ // could repaint in between and show the NEW model under the OLD server for a moment.
6141
+ activeCoderWhere = coderWhere(chosen.kind, chosen.url);
6122
6142
  // WRITTEN NOW, not at the end of the next turn: `/model` then `/exit` is how the choice is
6123
6143
  // made and then tested, and that path never reaches saveSession.
6124
6144
  await saveSessionCoding(resolve(process.cwd()));
6125
- activeCoderWhere = ollamaWhere(chosen.kind, chosen.url);
6126
6145
  const where = showTag && !activeCoderWhere ? ` (${tags[idx]})` : "";
6127
6146
  console.log(
6128
6147
  accent(
@@ -8644,6 +8663,16 @@ let defaultCodingModel = "";
8644
8663
  let sessionCodingUrl = "";
8645
8664
  /** "ollama.com" / "127.0.0.1" / a host — where the ACTIVE coder runs, when it is an Ollama one. */
8646
8665
  let activeCoderWhere = "";
8666
+ /**
8667
+ * Re-read where the coder in use runs (#263) — for the paths that change it without holding the
8668
+ * API's choice list (they clear the folder's pick back to the account default). Best-effort: an
8669
+ * unreachable API leaves no prefix rather than a stale one.
8670
+ */
8671
+ async function syncActiveCoderWhere() {
8672
+ const payload = await fetchAgentPayload([{ role: "user", content: "ping" }]).catch(() => null);
8673
+ activeCoderWhere = whereOfActiveCoder(payload?.codingChoices, sessionCodingModel);
8674
+ if (barPinned()) paintBar();
8675
+ }
8647
8676
  // The account-default backend's URL (from the startup probe's codingChoices), used when no
8648
8677
  // /model override is active. Set once at startup alongside defaultCodingModel.
8649
8678
  let defaultCodingUrl = "";
@@ -10527,14 +10556,9 @@ async function main() {
10527
10556
  }
10528
10557
  {
10529
10558
  // WHERE THE CODER RUNS, for the banner and the bar: the folder's pick, else the default.
10530
- const choices = Array.isArray(probe?.codingChoices) ? probe.codingChoices : [];
10531
- const active =
10532
- (sessionCodingModel && choices.find((c) => c.id === sessionCodingModel || c.model === sessionCodingModel)) ||
10533
- choices.find((c) => c.isDefault) ||
10534
- null;
10535
10559
  // No choice list (an older API): no prefix, rather than guess from the ACCOUNT default's raw
10536
10560
  // payload fields, which key-choice-regression.test.mjs forbids for exactly that reason.
10537
- activeCoderWhere = active ? ollamaWhere(active.kind, active.url) : "";
10561
+ activeCoderWhere = whereOfActiveCoder(probe?.codingChoices, sessionCodingModel);
10538
10562
  }
10539
10563
  // Task #133: no RAG storage question any more — the terminal ALWAYS keeps the index on
10540
10564
  // this machine. `/rag-cloud` opts into additionally mirroring it to the user's S3 bucket.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.734",
3
+ "version": "1.0.736",
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",