@tiens.nguyen/gu-cli 1.0.731 → 1.0.732

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.
package/add-server.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  * server kind ("dialect": an Ollama's /api/tags vs an OpenAI-compatible /v1/models) lives in
15
15
  * serverTarget() and listRequest().
16
16
  */
17
- import { defaultProviderUrl, ensureV1Root, hostLocality, isCancelWord, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
17
+ import { defaultProviderUrl, ensureV1Root, hostLocality, isAnthropicUrl, isCancelWord, modelListRequest, normalizeProviderUrl, parseProviderModels, MAX_MODELS_PER_SERVER } from "./coding-provider-setup.mjs";
18
18
 
19
19
  // Re-exported: the add-server screen's own name for the rule every URL prompt shares.
20
20
  export { isCancelWord };
@@ -112,10 +112,28 @@ export function serverTarget(dialect, typed) {
112
112
  host: r.host,
113
113
  locality: r.locality,
114
114
  cloud: false,
115
+ // The Claude API (#261): an OpenAI-compatible chat endpoint whose model LIST is Anthropic's own.
116
+ claude: isAnthropicUrl(r.url),
115
117
  roleUrl: () => r.url,
116
118
  };
117
119
  }
118
120
 
121
+ /**
122
+ * A server this role cannot use → the lines that say why, or [] when it can (#261). The Claude API
123
+ * serves no embedding models — Anthropic's own docs send embeddings to Voyage AI — so saving it as
124
+ * the embedder would only fail at the first index, several steps into a turn.
125
+ */
126
+ export function roleRefusal(role, target) {
127
+ if (role === "embed" && target?.claude) {
128
+ return [
129
+ "the Claude API has no embedding models — nothing saved.",
130
+ "Anthropic points to Voyage AI for embeddings (https://api.voyageai.com/v1),",
131
+ "or use an Ollama embedder such as nomic-embed-text.",
132
+ ];
133
+ }
134
+ return [];
135
+ }
136
+
119
137
  /**
120
138
  * Does this server need a key? "required" | "optional" | "none".
121
139
  * · An Ollama on this machine or the LAN answers anonymously — "none", not even asked.
@@ -129,8 +147,13 @@ export function keyRequirement(target) {
129
147
  return target.locality === "loopback" || target.locality === "private" ? "optional" : "required";
130
148
  }
131
149
 
132
- /** The request that lists a server's models: its URL and headers (a Bearer only when keyed). */
150
+ /**
151
+ * The request that lists a server's models: its URL and headers. An Ollama's /api/tags takes a
152
+ * Bearer when keyed (the cloud); an OpenAI-compatible root goes through modelListRequest, which
153
+ * knows the Claude API lists with x-api-key + anthropic-version instead (#261).
154
+ */
133
155
  export function listRequest(target, key = "") {
156
+ if (target?.dialect !== "ollama") return modelListRequest(target.root ?? target.url, key);
134
157
  const k = String(key ?? "").trim();
135
158
  return { url: target.listUrl, headers: k && k !== "local" ? { Authorization: `Bearer ${k}` } : {} };
136
159
  }
@@ -17,7 +17,7 @@
17
17
  * empty reads as "there are no models", which is never what a network error means.
18
18
  */
19
19
 
20
- import { modelRetired, ensureV1Root } from "./coding-provider-setup.mjs";
20
+ import { modelRetired, ensureV1Root, isAnthropicUrl, modelListRequest } from "./coding-provider-setup.mjs";
21
21
  import { isOllamaCloudUrl } from "./coding-key-prompt.mjs";
22
22
 
23
23
 
@@ -243,8 +243,9 @@ export function agentListSource(settings) {
243
243
  (b) => b && ensureV1Root(String(b.url ?? "")).toLowerCase() === want
244
244
  );
245
245
  if (row && String(row.kind ?? "").trim() === "openai") {
246
- // The row's url already ends at the /v1 root, so the models endpoint hangs directly off it.
247
- return { url: `${ensureV1Root(String(row.url ?? ""))}/models`, dialect: "openai", row };
246
+ // The row's url already ends at the /v1 root, so the models endpoint hangs directly off it
247
+ // (with the Claude API's page size — #261, see modelListRequest).
248
+ return { url: modelListRequest(ensureV1Root(String(row.url ?? ""))).url, dialect: "openai", row };
248
249
  }
249
250
  return { url: agentTagsUrl(settings), dialect: "ollama", row: row ?? null };
250
251
  }
@@ -277,8 +278,12 @@ export function agentSourceNeedsKey(source) {
277
278
  return source.dialect === "openai" || isOllamaCloudUrl(String(row.url ?? ""));
278
279
  }
279
280
 
280
- /** The headers a model-list request needs — the bearer only when there is a key to send. */
281
- export function agentListHeaders(key) {
281
+ /**
282
+ * The headers a model-list request needs — the bearer only when there is a key to send. The Claude
283
+ * API lists with x-api-key + anthropic-version instead (#261), so the URL is passed too.
284
+ */
285
+ export function agentListHeaders(key, url = "") {
286
+ if (isAnthropicUrl(url)) return modelListRequest(url.replace(/\/models(\?.*)?$/, ""), key).headers;
282
287
  const k = String(key ?? "").trim();
283
288
  return k ? { Authorization: `Bearer ${k}` } : {};
284
289
  }
@@ -198,6 +198,54 @@ export function modelsUrlFor(url) {
198
198
  return `${String(url ?? "").replace(/\/+$/, "")}/models`;
199
199
  }
200
200
 
201
+ /**
202
+ * THE CLAUDE API (task #261). Chat goes through Anthropic's OpenAI-compatible endpoint — base
203
+ * https://api.anthropic.com/v1, the key as the usual Bearer — so a Claude row is an ordinary
204
+ * "openai" row everywhere a turn runs. Only LISTING differs (Anthropic docs, api/models/list):
205
+ * GET /v1/models takes `x-api-key` + `anthropic-version`, not a Bearer, and pages (default 20,
206
+ * max 1000). Decided by the host, which is Anthropic's own API — not a guess from a name.
207
+ */
208
+ export const CLAUDE_API_URL = "https://api.anthropic.com/v1";
209
+ export const ANTHROPIC_VERSION = "2023-06-01";
210
+
211
+ export function isAnthropicUrl(url) {
212
+ try {
213
+ return new URL(String(url ?? "")).hostname.toLowerCase().replace(/\.$/, "") === "api.anthropic.com";
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The request that lists a /v1 root's models → { url, headers }. Anthropic: its two headers and
221
+ * one page of up to 1000 (it lists a few dozen). Everyone else: a Bearer only when keyed — "local"
222
+ * is the placeholder a keyless local server is given, never a key.
223
+ */
224
+ export function modelListRequest(url, key = "") {
225
+ const k = String(key ?? "").trim();
226
+ const keyed = Boolean(k) && k !== "local";
227
+ if (isAnthropicUrl(url)) {
228
+ return {
229
+ url: `${modelsUrlFor(url)}?limit=1000`,
230
+ headers: { "anthropic-version": ANTHROPIC_VERSION, ...(keyed ? { "x-api-key": k } : {}) },
231
+ };
232
+ }
233
+ return { url: modelsUrlFor(url), headers: keyed ? { Authorization: `Bearer ${k}` } : {} };
234
+ }
235
+
236
+ /**
237
+ * The hosted providers offered by name when adding one (#261), before "another URL". Claude has
238
+ * no embedding models (Anthropic points to Voyage AI), so it is not offered for the embedder.
239
+ */
240
+ export function providerPresets(role = "coding") {
241
+ const all = [
242
+ { name: "Claude API (Anthropic)", url: CLAUDE_API_URL, roles: ["coding", "agent"] },
243
+ { name: "OpenAI", url: DEFAULT_PROVIDER_URL, roles: ["coding", "agent", "embed"] },
244
+ { name: "Kimi (Moonshot)", url: "https://api.moonshot.ai/v1", roles: ["coding", "agent"] },
245
+ ];
246
+ return all.filter((p) => p.roles.includes(role));
247
+ }
248
+
201
249
  /** mlx_lm's placeholder, which is a mapping and not a model anyone can pick. */
202
250
  export const PLACEHOLDER_MODEL = "default_model";
203
251
 
@@ -321,8 +321,49 @@ export async function readBusyState() {
321
321
  }
322
322
  }
323
323
 
324
+ /**
325
+ * WHOSE DAEMON IS IT (task #257)? The `ps` scan sees every gu worker on the machine, but a worker
326
+ * belongs to ONE ~/.gonext: it polls that home's API with that home's key. Counting another
327
+ * home's worker as ours meant gu never started its own, and every turn sat "queued — waiting for
328
+ * the worker". Ours = OUR pidfile names it, or its HOME is ours. HOME unknown (Windows, or not
329
+ * readable) → counted, exactly as before: a second poller on a guess is the worse mistake.
330
+ */
331
+ export function ownsDaemon({ pid, pidFilePid, home, myHome = homedir() }) {
332
+ if (pidFilePid != null && pid === pidFilePid) return true;
333
+ if (home == null) return true;
334
+ const norm = (p) => String(p).replace(/[\\/]+$/, "");
335
+ return norm(home) === norm(myHome);
336
+ }
337
+
338
+ /** The HOME a process runs under → a path, or null when it cannot be read (Windows, gone, denied). */
339
+ export async function daemonHome(pid) {
340
+ if (!pid || !Number.isFinite(pid)) return null;
341
+ if (process.platform === "linux") {
342
+ try {
343
+ const env = await readFile(join("/proc", String(pid), "environ"), "utf8");
344
+ const hit = env.split("\0").find((kv) => kv.startsWith("HOME="));
345
+ return hit ? hit.slice(5) : null;
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+ if (process.platform !== "darwin") return null;
351
+ // macOS: `ps eww` appends the environment to the command line (own processes only).
352
+ return new Promise((resolve) => {
353
+ execFile("ps", ["eww", "-o", "command=", "-p", String(pid)], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
354
+ if (err) return resolve(null);
355
+ const m = / HOME=(\S+)/.exec(String(stdout || ""));
356
+ resolve(m ? m[1] : null);
357
+ });
358
+ });
359
+ }
360
+
324
361
  export async function daemonStatus() {
325
- const [pids, pidFilePid] = await Promise.all([findRunningDaemonPids(), readPidFile()]);
362
+ const [found, pidFilePid] = await Promise.all([findRunningDaemonPids(), readPidFile()]);
363
+ const pids = [];
364
+ for (const pid of found) {
365
+ if (ownsDaemon({ pid, pidFilePid, home: await daemonHome(pid) })) pids.push(pid);
366
+ }
326
367
  const pidFileIsDaemon =
327
368
  pidFilePid != null && (pids.includes(pidFilePid) || (await pidIsGuDaemon(pidFilePid)));
328
369
  const allPids = pidFileIsDaemon && !pids.includes(pidFilePid) ? [...pids, pidFilePid] : pids;
@@ -1013,16 +1013,229 @@ def _host_of(url) -> str:
1013
1013
  return str(url or "")
1014
1014
 
1015
1015
 
1016
+ def _is_anthropic_url(url: str) -> bool:
1017
+ """The Claude API (task #261): OpenAI-compatible chat, but its /v1/models takes x-api-key +
1018
+ anthropic-version rather than a Bearer, and pages. Decided by Anthropic's own host."""
1019
+ from urllib.parse import urlparse
1020
+ return (urlparse(url or "").hostname or "").lower().rstrip(".") == "api.anthropic.com"
1021
+
1022
+
1023
+ def _model_list_request(base_url: str, api_key: str = ""):
1024
+ """(url, headers) that list the models of a /v1 root — same rule as the terminal's
1025
+ modelListRequest (coding-provider-setup.mjs)."""
1026
+ url = (base_url or "").rstrip("/") + "/models"
1027
+ headers = {"Accept": "application/json"}
1028
+ keyed = bool(api_key) and api_key != "local"
1029
+ if _is_anthropic_url(base_url):
1030
+ headers["anthropic-version"] = "2023-06-01"
1031
+ if keyed:
1032
+ headers["x-api-key"] = api_key
1033
+ return url + "?limit=1000", headers
1034
+ if keyed:
1035
+ headers["Authorization"] = f"Bearer {api_key}"
1036
+ return url, headers
1037
+
1038
+
1039
+ # ---- the Claude API, natively, for prompt caching (task #262) ----------------------------------
1040
+ #
1041
+ # Anthropic's OpenAI-compatible endpoint (#261) does NOT cache: every agent step re-sent the whole
1042
+ # prompt at full price, and measured over 1,308 steps 96.4% of what a step reads is repetition. The
1043
+ # native Messages API caches a PREFIX, but only where the request marks it. So the coding model on
1044
+ # api.anthropic.com talks to the native API through this adapter, which speaks the one interface the
1045
+ # rest of the agent already uses — client.chat.completions.create(), streamed or not, returning
1046
+ # OpenAI-shaped chunks — so streaming, the runaway guards, token counts and the cache counters work
1047
+ # unchanged. Our prompt order is already stable-first (#86); the adapter only marks it:
1048
+ # 1. the system prompt (the scaffold — identical for the whole gu session);
1049
+ # 2. the end of the STATIC tool reference inside the first user message, split off the volatile
1050
+ # tail at "----- YOUR TASK -----" (date, deploy target, folder overview, the task);
1051
+ # 3. top-level automatic caching for the step history that grows inside a turn.
1052
+ # Default 5-minute TTL: steps inside a turn are well under 5 minutes apart.
1053
+
1054
+ _TASK_SPLIT_MARKER = "\n----- YOUR TASK -----\n"
1055
+
1056
+
1057
+ def _anthropic_image_block(url: str):
1058
+ """An OpenAI image_url → an Anthropic image block (data: URLs as base64, else by URL)."""
1059
+ m = re.match(r"^data:([^;,]+);base64,(.*)$", url or "", re.S)
1060
+ if m:
1061
+ return {"type": "image", "source": {"type": "base64", "media_type": m.group(1), "data": m.group(2)}}
1062
+ return {"type": "image", "source": {"type": "url", "url": url}}
1063
+
1064
+
1065
+ def _anthropic_blocks(content) -> list:
1066
+ """OpenAI message content (a string or a list of parts) → Anthropic content blocks.
1067
+ Empty text is dropped: the Messages API rejects an empty text block."""
1068
+ if isinstance(content, str):
1069
+ return [{"type": "text", "text": content}] if content.strip() else []
1070
+ out = []
1071
+ for part in content or []:
1072
+ if not isinstance(part, dict):
1073
+ continue
1074
+ if part.get("type") == "text" and str(part.get("text") or "").strip():
1075
+ out.append({"type": "text", "text": str(part["text"])})
1076
+ elif part.get("type") == "image_url":
1077
+ url = (part.get("image_url") or {}).get("url") if isinstance(part.get("image_url"), dict) else part.get("image_url")
1078
+ if url:
1079
+ out.append(_anthropic_image_block(str(url)))
1080
+ return out
1081
+
1082
+
1083
+ def _anthropic_request(kw: dict) -> dict:
1084
+ """OpenAI chat.completions kwargs → Anthropic messages.create kwargs, with the cache marks."""
1085
+ ephemeral = {"type": "ephemeral"}
1086
+ system_blocks, messages = [], []
1087
+ for m in kw.get("messages") or []:
1088
+ role = m.get("role") if isinstance(m, dict) else getattr(m, "role", "")
1089
+ content = m.get("content") if isinstance(m, dict) else getattr(m, "content", "")
1090
+ role = str(getattr(role, "value", role) or "")
1091
+ blocks = _anthropic_blocks(content)
1092
+ if not blocks:
1093
+ continue
1094
+ if role in ("system", "developer"):
1095
+ system_blocks.extend(b for b in blocks if b["type"] == "text")
1096
+ continue
1097
+ role = "assistant" if role == "assistant" else "user"
1098
+ if messages and messages[-1]["role"] == role:
1099
+ messages[-1]["content"].extend(blocks) # the API would merge them anyway; be explicit
1100
+ else:
1101
+ messages.append({"role": role, "content": blocks})
1102
+ if not messages or messages[0]["role"] != "user":
1103
+ messages.insert(0, {"role": "user", "content": [{"type": "text", "text": "(start)"}]})
1104
+ # No assistant prefill: current Claude models reject a conversation that ENDS on the assistant.
1105
+ if messages[-1]["role"] == "assistant":
1106
+ messages.append({"role": "user", "content": [{"type": "text", "text": "Continue."}]})
1107
+
1108
+ # Mark 2: the static reference at the head of the first user message.
1109
+ first = messages[0]["content"]
1110
+ for i, b in enumerate(first):
1111
+ if b["type"] == "text" and _TASK_SPLIT_MARKER in b["text"]:
1112
+ head, tail = b["text"].split(_TASK_SPLIT_MARKER, 1)
1113
+ first[i:i + 1] = [
1114
+ {"type": "text", "text": head, "cache_control": ephemeral},
1115
+ {"type": "text", "text": _TASK_SPLIT_MARKER.lstrip("\n") + tail},
1116
+ ]
1117
+ break
1118
+ # Mark 1: the system prompt.
1119
+ if system_blocks:
1120
+ system_blocks[-1] = {**system_blocks[-1], "cache_control": ephemeral}
1121
+
1122
+ stops = [s for s in (kw.get("stop") or []) if isinstance(s, str) and s.strip()]
1123
+ req = {
1124
+ "model": kw.get("model"),
1125
+ "max_tokens": int(kw.get("max_tokens") or kw.get("max_completion_tokens") or 32000),
1126
+ "messages": messages,
1127
+ # Mark 3: automatic caching for the growing tail (the step history).
1128
+ "cache_control": ephemeral,
1129
+ }
1130
+ if system_blocks:
1131
+ req["system"] = system_blocks
1132
+ if stops:
1133
+ req["stop_sequences"] = stops[:8]
1134
+ # NO temperature / top_p: current Claude models reject sampling parameters with a 400, and
1135
+ # the thinking / effort settings are left at the model's defaults so they never vary per
1136
+ # request (a change there invalidates the cache).
1137
+ return req
1138
+
1139
+
1140
+ _ANTHROPIC_FINISH = {"end_turn": "stop", "stop_sequence": "stop", "max_tokens": "length",
1141
+ "refusal": "stop", "pause_turn": "stop", "tool_use": "tool_calls"}
1142
+
1143
+
1144
+ def _anthropic_usage(u) -> "types.SimpleNamespace":
1145
+ """Anthropic usage → the OpenAI-shaped usage the agent reads. prompt_tokens is the WHOLE prompt
1146
+ (input_tokens is only the uncached remainder), and the cache counts ride along under their own
1147
+ names — _cached_tokens_from_usage already reads cache_read_input_tokens."""
1148
+ import types
1149
+ g = (lambda n: int(_usage_field(u, n) or 0))
1150
+ cr, cw, inp = g("cache_read_input_tokens"), g("cache_creation_input_tokens"), g("input_tokens")
1151
+ return types.SimpleNamespace(prompt_tokens=inp + cr + cw, completion_tokens=g("output_tokens"),
1152
+ cache_read_input_tokens=cr, cache_creation_input_tokens=cw)
1153
+
1154
+
1155
+ class _AnthropicChatAdapter:
1156
+ """client.chat.completions.create() backed by Anthropic's native Messages API (task #262)."""
1157
+
1158
+ def __init__(self, api_key: str = "", base_url: str = "", timeout=None, sdk_client=None):
1159
+ if sdk_client is None:
1160
+ import anthropic # the caller checked it imports
1161
+ kwargs = {"api_key": api_key or None, "max_retries": 0}
1162
+ root = re.sub(r"/v1/?$", "", (base_url or "").rstrip("/"))
1163
+ if root:
1164
+ kwargs["base_url"] = root
1165
+ if timeout:
1166
+ kwargs["timeout"] = timeout
1167
+ if _PROXY_URL:
1168
+ kwargs["http_client"] = anthropic.DefaultHttpxClient(proxy=_PROXY_URL)
1169
+ sdk_client = anthropic.Anthropic(**kwargs)
1170
+ self._sdk = sdk_client
1171
+ import types
1172
+ self.chat = types.SimpleNamespace(completions=types.SimpleNamespace(create=self._create))
1173
+
1174
+ def _create(self, **kw):
1175
+ req = _anthropic_request(kw)
1176
+ if kw.get("stream"):
1177
+ return self._stream(req)
1178
+ msg = self._sdk.messages.create(**req)
1179
+ import types
1180
+ text = "".join(getattr(b, "text", "") or "" for b in (msg.content or []) if getattr(b, "type", "") == "text")
1181
+ return types.SimpleNamespace(
1182
+ choices=[types.SimpleNamespace(
1183
+ message=types.SimpleNamespace(role="assistant", content=text, tool_calls=None),
1184
+ finish_reason=_ANTHROPIC_FINISH.get(getattr(msg, "stop_reason", "") or "", "stop"))],
1185
+ usage=_anthropic_usage(getattr(msg, "usage", None)))
1186
+
1187
+ def _stream(self, req):
1188
+ import types
1189
+
1190
+ def chunk(content=None, reasoning=None, finish=None, usage=None):
1191
+ delta = types.SimpleNamespace(content=content, reasoning=reasoning, reasoning_content=None,
1192
+ role=None, tool_calls=None)
1193
+ choices = [] if (content is None and reasoning is None and finish is None) else [
1194
+ types.SimpleNamespace(delta=delta, finish_reason=finish, index=0)]
1195
+ return types.SimpleNamespace(choices=choices, usage=usage)
1196
+
1197
+ usage = {}
1198
+ stop_reason = ""
1199
+ with self._sdk.messages.stream(**req) as events:
1200
+ for ev in events:
1201
+ et = getattr(ev, "type", "")
1202
+ if et == "message_start":
1203
+ u = getattr(getattr(ev, "message", None), "usage", None)
1204
+ for n in ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens", "output_tokens"):
1205
+ usage[n] = int(_usage_field(u, n) or 0)
1206
+ elif et == "content_block_delta":
1207
+ d = getattr(ev, "delta", None)
1208
+ dt = getattr(d, "type", "")
1209
+ if dt == "text_delta" and getattr(d, "text", ""):
1210
+ yield chunk(content=d.text)
1211
+ elif dt == "thinking_delta" and getattr(d, "thinking", ""):
1212
+ yield chunk(reasoning=d.thinking)
1213
+ elif et == "message_delta":
1214
+ stop_reason = getattr(getattr(ev, "delta", None), "stop_reason", "") or stop_reason
1215
+ out = _usage_field(getattr(ev, "usage", None), "output_tokens")
1216
+ if out is not None:
1217
+ usage["output_tokens"] = int(out)
1218
+ if stop_reason == "refusal":
1219
+ _log("claude: the model declined this request (stop_reason=refusal)")
1220
+ yield chunk(content="", finish=_ANTHROPIC_FINISH.get(stop_reason, "stop"))
1221
+ yield chunk(usage=_anthropic_usage(usage))
1222
+
1223
+
1224
+ def _anthropic_sdk_available() -> bool:
1225
+ try:
1226
+ import anthropic # noqa: F401
1227
+ return True
1228
+ except Exception: # noqa: BLE001
1229
+ return False
1230
+
1231
+
1016
1232
  def _list_model_ids(base_url, api_key=""):
1017
1233
  """Return the model ids an OpenAI-compatible server reports at {base_url}/models.
1018
1234
 
1019
1235
  `base_url` already ends with /v1. Returns [] on any failure so callers can
1020
1236
  fall back.
1021
1237
  """
1022
- url = base_url.rstrip("/") + "/models"
1023
- headers = {"Accept": "application/json"}
1024
- if api_key and api_key != "local":
1025
- headers["Authorization"] = f"Bearer {api_key}"
1238
+ url, headers = _model_list_request(base_url, api_key)
1026
1239
  req = urllib.request.Request(url, method="GET", headers=headers)
1027
1240
  try:
1028
1241
  with urllib.request.urlopen(req, timeout=10, context=_ssl_context_insecure()) as resp:
@@ -2880,7 +3093,7 @@ def _provider_reason(err, limit: int = 180) -> str:
2880
3093
  _CTX_WINDOW_CACHE: dict = {}
2881
3094
 
2882
3095
 
2883
- def _model_context_window(base_url: str, model_id: str) -> int:
3096
+ def _model_context_window(base_url: str, model_id: str, api_key: str = "") -> int:
2884
3097
  """The coding model's REAL context window, or 0 when it cannot be known.
2885
3098
 
2886
3099
  Ollama knows this exactly and will tell us for free (no GPU work): /api/show returns the
@@ -2946,14 +3159,17 @@ def _model_context_window(base_url: str, model_id: str) -> int:
2946
3159
  # say keeps win at 0 and the caller falls back with its eyes open.
2947
3160
  if win <= 0:
2948
3161
  try:
2949
- req = urllib.request.Request(f"{root}/v1/models", method="GET")
3162
+ list_url, list_headers = _model_list_request(f"{root}/v1", api_key)
3163
+ req = urllib.request.Request(list_url, method="GET", headers=list_headers)
2950
3164
  with urllib.request.urlopen(req, timeout=10, context=_ssl_context()) as resp:
2951
3165
  data = json.loads(resp.read().decode("utf-8", "replace"))
2952
3166
  for row in (data.get("data") or []):
2953
3167
  if str(row.get("id") or "") != str(model_id):
2954
3168
  continue
3169
+ # max_input_tokens: the Claude API's own field (#261) — its input cap, which is
3170
+ # exactly the number this protects (see _KNOWN_CTX_WINDOWS on why input, not window).
2955
3171
  for field in ("max_model_len", "context_length", "context_window",
2956
- "max_context_length"):
3172
+ "max_context_length", "max_input_tokens"):
2957
3173
  v = row.get(field)
2958
3174
  if isinstance(v, int) and v > 4096:
2959
3175
  win = v
@@ -3042,6 +3258,11 @@ _KNOWN_CTX_WINDOWS: list = [
3042
3258
  # Kimi K3's own README states 1048576. An earlier version of this table said 256,000 — off by
3043
3259
  # a factor of four, on the model this feature was built for.
3044
3260
  (r"^kimi-k3", 1_048_576, "https://github.com/MoonshotAI/Kimi-K3/blob/main/README.md"),
3261
+ # Claude (#261). The Models API reports max_input_tokens itself (read above), so these only
3262
+ # matter when the list cannot be read. Haiku 4.5 first: the general row would give it 1M.
3263
+ (r"^claude-haiku-4-5", 200_000, "https://platform.claude.com/docs/en/about-claude/models/overview"),
3264
+ (r"^claude-(opus|sonnet|fable|mythos)-(5|4-[678])", 1_000_000,
3265
+ "https://platform.claude.com/docs/en/about-claude/models/overview"),
3045
3266
  (r"^kimi-k2", 256_000, "https://platform.kimi.ai/docs/api/chat.en-US"),
3046
3267
  (r"^kimi-k1", 128_000, "unverified — no vendor page found; corrected on first refusal"),
3047
3268
  # These three state their size in the name, which is its own citation.
@@ -4160,64 +4381,6 @@ def _ssl_context():
4160
4381
  return ssl.create_default_context()
4161
4382
 
4162
4383
 
4163
- def _pdf_upload_via_api(api_base: str, worker_key: str, file_name: str,
4164
- pdf_bytes: bytes) -> str:
4165
- """Ask the API to presign an S3 upload, PUT the PDF bytes, return the download URL.
4166
-
4167
- Keeps all AWS credentials on the API/Lambda — the worker only holds its worker key.
4168
- """
4169
- import urllib.request as _u
4170
- import urllib.error as _ue
4171
-
4172
- ctx = _ssl_context()
4173
- base = (api_base or "").rstrip("/")
4174
- if not base or not worker_key:
4175
- raise RuntimeError("PDF upload is not available: worker API base/key missing.")
4176
-
4177
- # 1) Presign.
4178
- req = _u.Request(
4179
- f"{base}/api/worker/pdf-upload-url",
4180
- data=json.dumps({"fileName": file_name}).encode("utf-8"),
4181
- headers={
4182
- "Content-Type": "application/json",
4183
- "X-Worker-Key": worker_key,
4184
- },
4185
- method="POST",
4186
- )
4187
- try:
4188
- with _u.urlopen(req, timeout=30, context=ctx) as resp:
4189
- ref = json.loads(resp.read().decode("utf-8"))
4190
- except _ue.HTTPError as e: # noqa: PERF203
4191
- detail = e.read().decode("utf-8", "replace")[:300]
4192
- raise RuntimeError(f"Could not get a PDF upload URL (HTTP {e.code}): {detail}")
4193
- except _ue.URLError as e:
4194
- raise RuntimeError(
4195
- f"Could not reach the API at {base} to presign the upload "
4196
- f"({getattr(e, 'reason', e)}). Is the worker API deployed/online?"
4197
- )
4198
- put_url = ref.get("putUrl")
4199
- get_url = ref.get("getUrl")
4200
- if not put_url or not get_url:
4201
- raise RuntimeError("PDF upload URL response was incomplete.")
4202
-
4203
- # 2) Upload the bytes to the presigned PUT URL.
4204
- put_req = _u.Request(
4205
- put_url, data=pdf_bytes,
4206
- headers={"Content-Type": "application/pdf"},
4207
- method="PUT",
4208
- )
4209
- try:
4210
- with _u.urlopen(put_req, timeout=60, context=ctx) as up:
4211
- if up.status not in (200, 201, 204):
4212
- raise RuntimeError(f"S3 upload failed (HTTP {up.status}).")
4213
- except _ue.HTTPError as e: # noqa: PERF203
4214
- detail = e.read().decode("utf-8", "replace")[:200]
4215
- raise RuntimeError(f"S3 upload failed (HTTP {e.code}): {detail}")
4216
- except _ue.URLError as e:
4217
- raise RuntimeError(f"S3 upload could not connect ({getattr(e, 'reason', e)}).")
4218
- return get_url
4219
-
4220
-
4221
4384
  # Generic download support (task #102) — deliver ANY workspace file/folder as a download.
4222
4385
  _DOWNLOAD_MAX_BYTES = 100 * 1024 * 1024 # 100 MB cap on what we'll upload for download
4223
4386
 
@@ -4249,8 +4412,20 @@ def _artifact_local_path(file_name: str) -> str:
4249
4412
  return candidate
4250
4413
 
4251
4414
 
4415
+ def _save_artifact_locally(file_name: str, data: bytes, summary: str, why: str = "") -> str:
4416
+ dest = _artifact_local_path(file_name)
4417
+ with open(dest, "wb") as fh:
4418
+ fh.write(data)
4419
+ _emit({"type": "step", "text": f"Saved → {dest}"})
4420
+ _log(f"artifact saved locally path={_c(dest)} bytes={len(data)}")
4421
+ return (f"✅ \"{os.path.basename(dest)}\" is ready ({summary}).\n"
4422
+ + (f"{why}\n" if why else "")
4423
+ + f"Saved on this machine:\n{dest}")
4424
+
4425
+
4252
4426
  def _deliver_artifact(file_name: str, data: bytes, content_type: str, summary: str,
4253
- *, local: bool, api_base: str = "", worker_key: str = "") -> str:
4427
+ *, local: bool, api_base: str = "", worker_key: str = "",
4428
+ ask_s3_setup=None) -> str:
4254
4429
  """Hand a finished artifact to the user — as a FILE ON THIS MACHINE, or as a link.
4255
4430
 
4256
4431
  LOCAL IS THE DEFAULT IN CLIENT EXPERT and it is not a degraded fallback: when the user is
@@ -4262,14 +4437,26 @@ def _deliver_artifact(file_name: str, data: bytes, content_type: str, summary: s
4262
4437
  the delivery itself.
4263
4438
  """
4264
4439
  if local:
4265
- dest = _artifact_local_path(file_name)
4266
- with open(dest, "wb") as fh:
4267
- fh.write(data)
4268
- _emit({"type": "step", "text": f"Saved → {dest}"})
4269
- _log(f"artifact saved locally path={dest!r} bytes={len(data)}")
4270
- return (f"✅ \"{os.path.basename(dest)}\" is ready ({summary}).\n"
4271
- f"Saved on this machine:\n{dest}")
4272
- url = _artifact_upload_via_api(api_base, worker_key, file_name, data, content_type)
4440
+ return _save_artifact_locally(file_name, data, summary)
4441
+ # PRIVATE MODE WITHOUT THE USER'S OWN S3 (task #252): the API says so instead of uploading to
4442
+ # anyone else's bucket. Ask whether to set S3 up now; otherwise the file stays on this computer.
4443
+ try:
4444
+ url = _artifact_upload_via_api(api_base, worker_key, file_name, data, content_type)
4445
+ except _S3NotSetUp:
4446
+ chose = False
4447
+ try:
4448
+ chose = bool(ask_s3_setup and ask_s3_setup())
4449
+ except Exception as e: # noqa: BLE001 — asking must never lose the file
4450
+ _log(f"s3 setup question failed: {type(e).__name__}")
4451
+ if not chose:
4452
+ return _save_artifact_locally(
4453
+ file_name, data, summary,
4454
+ "S3 is not set up, so it was not uploaded anywhere (run /s3 in gu to use your own bucket).")
4455
+ try:
4456
+ url = _artifact_upload_via_api(api_base, worker_key, file_name, data, content_type)
4457
+ except _S3NotSetUp:
4458
+ return _save_artifact_locally(
4459
+ file_name, data, summary, "S3 is still not set up, so it was not uploaded anywhere.")
4273
4460
  _emit({"type": "step", "text": f"Download ready → {file_name}"})
4274
4461
  _log(f"artifact uploaded name={file_name!r} bytes={len(data)}")
4275
4462
  return (f"✅ \"{file_name}\" is ready ({summary}).\n"
@@ -4292,10 +4479,15 @@ def _content_type_for(name: str) -> str:
4292
4479
  return _DOWNLOAD_CONTENT_TYPES.get(ext, "application/octet-stream")
4293
4480
 
4294
4481
 
4482
+ class _S3NotSetUp(RuntimeError):
4483
+ """Private mode, and the user has no S3 of their own (task #252). The API refused to sign —
4484
+ without touching the network — so the caller asks the user, or saves the file on disk."""
4485
+
4486
+
4295
4487
  def _artifact_upload_via_api(api_base: str, worker_key: str, file_name: str,
4296
4488
  data: bytes, content_type: str = "") -> str:
4297
4489
  """Presign a GENERIC S3 upload (any file type), PUT the bytes, return the download URL.
4298
- Like _pdf_upload_via_api but preserves the real extension + Content-Type. The PUT
4490
+ Like the old PDF-only uploader, but preserves the real extension + Content-Type. The PUT
4299
4491
  Content-Type MUST match what the presign signed, so we send content_type in the presign
4300
4492
  request and PUT with the value the API echoes back (task #102)."""
4301
4493
  import urllib.error as _ue
@@ -4316,6 +4508,8 @@ def _artifact_upload_via_api(api_base: str, worker_key: str, file_name: str,
4316
4508
  ref = json.loads(resp.read().decode("utf-8"))
4317
4509
  except _ue.HTTPError as e: # noqa: PERF203
4318
4510
  detail = e.read().decode("utf-8", "replace")[:300]
4511
+ if e.code == 409 and "S3_NOT_SET_UP" in detail:
4512
+ raise _S3NotSetUp(detail) from None
4319
4513
  raise RuntimeError(f"Could not get an upload URL (HTTP {e.code}): {detail}")
4320
4514
  except _ue.URLError as e:
4321
4515
  raise RuntimeError(
@@ -6448,7 +6642,7 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
6448
6642
  the coder can be slow, so it passes a much larger deadline (#110): the REPL now
6449
6643
  dismisses the picker the instant this job ends, and the worker's own job-cap kills a
6450
6644
  truly-abandoned run, so a long wait here is safe and lets a real "Yes" actually land.
6451
- Mirrors _pdf_upload_via_api's worker-key auth — no new creds on the worker."""
6645
+ Same worker-key auth as _artifact_upload_via_api — no new creds on the worker."""
6452
6646
  import urllib.request as _u
6453
6647
  import uuid as _uuid
6454
6648
  import time as _t
@@ -6485,6 +6679,8 @@ def _ws_request_approval(api_base, worker_key, job_id, command, reason, deadline
6485
6679
  _emit({"type": "step", "text": "Awaiting your choice: continue past the step budget?"})
6486
6680
  elif command.startswith("__COMPACT__::"):
6487
6681
  _emit({"type": "step", "text": "Awaiting your choice: compact the context?"})
6682
+ elif command.startswith("__S3SETUP__::"):
6683
+ _emit({"type": "step", "text": "Awaiting your choice: set up S3, or save on this computer?"})
6488
6684
  else:
6489
6685
  _emit({"type": "step", "text": f"Awaiting your approval to run: {command[:70]}"})
6490
6686
  deadline = _t.time() + max(30, int(deadline_s)) # per-call; risky=180s, __MAXSTEP__ long
@@ -6863,6 +7059,17 @@ def run_agent_chat(cfg):
6863
7059
  # risky command and asks the user via the API instead of hard-blocking it.
6864
7060
  _job_id = (cfg.get("jobId") or "").strip()
6865
7061
  _interactive_approval = bool(cfg.get("interactiveApproval"))
7062
+
7063
+ def _ask_s3_setup() -> bool:
7064
+ """Private mode, a file to hand over, no S3 of the user's own (task #252): ask in the
7065
+ terminal whether to set it up now. True only when it is set up AND tested. No terminal to
7066
+ ask → False, and the file is saved on this computer. The wait covers typing the setup."""
7067
+ if not (_interactive_approval and _job_id):
7068
+ return False
7069
+ return _ws_request_approval(
7070
+ worker_api_base, worker_api_key, _job_id,
7071
+ "__S3SETUP__::S3 is not set up for your files. Set it up now, or save this file on this computer?",
7072
+ "", deadline_s=900)
6866
7073
  # "accept edits on/off" from the terminal's bar (task #179). DEFAULTS TO TRUE, and has to:
6867
7074
  # an older terminal, the web, and any caller that does not know about the flag send nothing,
6868
7075
  # and reading a missing flag as "off" would put a Yes/No question in front of every command
@@ -7770,6 +7977,7 @@ def run_agent_chat(cfg):
7770
7977
  out = _deliver_artifact(
7771
7978
  f"{doc_title}.pdf", pdf_bytes, "application/pdf", "PDF",
7772
7979
  local=local_artifacts, api_base=worker_api_base, worker_key=worker_api_key,
7980
+ ask_s3_setup=_ask_s3_setup,
7773
7981
  )
7774
7982
  except Exception as e: # noqa: BLE001
7775
7983
  _log(f"PDF fast-path delivery error: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
@@ -8696,23 +8904,22 @@ def run_agent_chat(cfg):
8696
8904
  _last_obs["text"] = msg
8697
8905
  return msg
8698
8906
 
8699
- # 3) Upload via the API-presigned PUT (no AWS creds on the worker).
8907
+ # 3) Hand it over: a link via the API-presigned PUT (no AWS creds on the worker), or — in
8908
+ # private mode without the user's own S3 — on this computer (task #252, same path as the
8909
+ # PDF fast-path and create_download).
8700
8910
  _emit({"type": "step", "text": "Uploading PDF…"})
8701
8911
  file_name = f"{doc_title}.pdf"
8702
8912
  try:
8703
- download_url = _pdf_upload_via_api(
8704
- worker_api_base, worker_api_key, file_name, pdf_bytes
8913
+ out = _deliver_artifact(
8914
+ file_name, pdf_bytes, "application/pdf", "PDF",
8915
+ local=local_artifacts, api_base=worker_api_base, worker_key=worker_api_key,
8916
+ ask_s3_setup=_ask_s3_setup,
8705
8917
  )
8706
8918
  except Exception as e: # noqa: BLE001 — network / API errors must not crash the tool
8707
8919
  _log(f"create_pdf upload error: {type(e).__name__}: {e!r}\n{traceback.format_exc()}")
8708
8920
  msg = f"PDF was created but could not be uploaded: {str(e)[:200]}"
8709
8921
  _last_obs["text"] = msg
8710
8922
  return msg
8711
-
8712
- out = (
8713
- f"✅ Your PDF \"{doc_title}\" is ready.\n"
8714
- f"Download it here (link valid for a limited time):\n{download_url}"
8715
- )
8716
8923
  _emit({"type": "step", "text": f"PDF ready → {doc_title}.pdf"})
8717
8924
  _log(f"create_pdf ok title={_c(doc_title, r=True)} bytes={len(pdf_bytes)}")
8718
8925
  _pdf_state["last"] = {
@@ -8810,6 +9017,7 @@ def run_agent_chat(cfg):
8810
9017
  out = _deliver_artifact(
8811
9018
  file_name, data, content_type, summary,
8812
9019
  local=local_artifacts, api_base=worker_api_base, worker_key=worker_api_key,
9020
+ ask_s3_setup=_ask_s3_setup,
8813
9021
  )
8814
9022
  _last_obs["text"] = out
8815
9023
  return out
@@ -8838,7 +9046,7 @@ def run_agent_chat(cfg):
8838
9046
  # Per-turn, like _compact_state above: the high-water mark that decides when a trim
8839
9047
  # pass is worth breaking the prefix cache for. See _trim_due.
8840
9048
  _trim_state: dict = {"next_at": _TRIM_FIRST_AT_CHARS}
8841
- _ctx_window = _model_context_window(coding_base_url, coding_model_id)
9049
+ _ctx_window = _model_context_window(coding_base_url, coding_model_id, coding_api_key)
8842
9050
  _compact_at = _compact_threshold(_ctx_window)
8843
9051
  if _ctx_window:
8844
9052
  # SAY WHICH SOURCE ANSWERED. A measured window and a remembered one are not the same
@@ -9239,6 +9447,22 @@ def run_agent_chat(cfg):
9239
9447
  self._no_code_streak = 0
9240
9448
  super().__init__(*a, **kw)
9241
9449
 
9450
+ def create_client(self):
9451
+ """The Claude API goes native, for prompt caching (task #262); everything else — and
9452
+ the Claude API when Anthropic's SDK is not installed — keeps the OpenAI client."""
9453
+ ck = self.client_kwargs or {}
9454
+ base = str(ck.get("base_url") or "")
9455
+ if _is_anthropic_url(base):
9456
+ if _anthropic_sdk_available():
9457
+ _log("claude: native Messages API with prompt caching")
9458
+ return _AnthropicChatAdapter(api_key=str(ck.get("api_key") or ""), base_url=base,
9459
+ timeout=ck.get("timeout"))
9460
+ _log("claude: the anthropic package is not installed — using the OpenAI-compatible "
9461
+ "endpoint, which does not cache")
9462
+ _emit({"type": "step", "text": "Claude without prompt caching — every step pays full "
9463
+ "price. Install it once: pip install anthropic"})
9464
+ return super().create_client()
9465
+
9242
9466
  def _stream_key(self):
9243
9467
  """(server, model) — the pair the streaming memo is keyed on. `api_base` is a
9244
9468
  constructor ARGUMENT in smolagents, not an attribute: it survives only inside
package/gu-repl.mjs CHANGED
@@ -800,7 +800,7 @@ const COMMANDS = [
800
800
  { name: "/host-mode", desc: "undo client mode — run agent turns on THIS machine again" },
801
801
  { name: "/server", desc: "pick a deployment server (host/user) for this session" },
802
802
  { name: "/proxy", usage: "/proxy [show|set|on|off|clear|test]", desc: "route MODEL calls through an HTTP proxy (local calls always go direct)" },
803
- { name: "/s3", usage: "/s3 [test]", desc: "set up the S3 bucket the RAG index lives in, and check the AWS keys work" },
803
+ { name: "/s3", usage: "/s3 [test]", desc: "set up YOUR S3 bucket (RAG index, and the PDFs/downloads gu makes) and check the AWS keys work — without it, files are saved on this computer" },
804
804
  { name: "/context-window", usage: "/context-window [tokens|auto]", desc: "tell gu how big the coding model's context is — hosted models cannot be probed" },
805
805
  { name: "/save-settings", usage: "/save-settings [name|path]", desc: "write this account's providers, models and key NAMES to a file in your home folder (no secrets) — asks for a name, Enter keeps gu-settings.json" },
806
806
  { name: "/load-settings", usage: "/load-settings [name|path]", desc: "read that file back on another machine — picks from the settings files in your home folder, asks before it replaces anything" },
@@ -1515,6 +1515,11 @@ rl.on("line", (l) => {
1515
1515
  if (following) {
1516
1516
  rl.line = "";
1517
1517
  rl.cursor = 0;
1518
+ // No frozen row to carry the notice (piped, or a window too short to pin one): say it once
1519
+ // here, on the Enter that would have sent it (#258).
1520
+ if (typedDuringTurn && !barPinned()) {
1521
+ process.stdout.write(dim(`\n ${TYPED_DURING_TURN_NOTICE.trim()}\n`));
1522
+ }
1518
1523
  return;
1519
1524
  }
1520
1525
  // Enter left the cursor on the row where the live slash-hint started — erase it so the
@@ -2263,7 +2268,12 @@ function frozenZone() {
2263
2268
  const zone = inputZoneRows({
2264
2269
  headControl,
2265
2270
  width,
2266
- prompt: secretInput && secretPrompt ? accent(secretPrompt) : inlinePrompt ? accent(inlinePrompt) : PROMPT,
2271
+ // #258: typing during a turn is not sent — the row says so (an ask() label still comes first).
2272
+ prompt: secretInput && secretPrompt
2273
+ ? accent(secretPrompt)
2274
+ : following && typedDuringTurn && !inlinePrompt
2275
+ ? yellow(typedNotice(width))
2276
+ : inlinePrompt ? accent(inlinePrompt) : PROMPT,
2267
2277
  // WHILE A SECRET IS OPEN, THIS ROW IS THE PROMPT — "kimi-k3 API key: ••••" rather than
2268
2278
  // ">> ••••". Same row, same masking; what changes is that it now says what it wants. The
2269
2279
  // width is measured, not assumed: the label is caller-supplied and every offset in here
@@ -2271,9 +2281,11 @@ function frozenZone() {
2271
2281
  promptCols:
2272
2282
  secretInput && secretPrompt
2273
2283
  ? visibleLen(secretPrompt)
2274
- : inlinePrompt
2275
- ? visibleLen(inlinePrompt)
2276
- : PROMPT_COLS,
2284
+ : following && typedDuringTurn && !inlinePrompt
2285
+ ? visibleLen(typedNotice(width))
2286
+ : inlinePrompt
2287
+ ? visibleLen(inlinePrompt)
2288
+ : PROMPT_COLS,
2277
2289
  // A SECRET IS MASKED HERE, NOT HIDDEN (task #191, and a #127 hole the frozen zone opened).
2278
2290
  // askSecret mutes readline's echo, but this zone does not go through readline — gu
2279
2291
  // renders it from rl.line — so while the key prompt was up, the characters being typed
@@ -3621,6 +3633,13 @@ if (process.stdin.isTTY) {
3621
3633
  // so the switch did nothing while the agent was thinking — which is exactly when a user
3622
3634
  // watching it reach for commands wants to change it.
3623
3635
  if (key && key.name === "tab" && key.shift) switchEditMode();
3636
+ // A printable key (or Enter after one) means the user is typing a question that will not
3637
+ // be sent — say so in the input row now, not after they have typed the whole thing (#258).
3638
+ const printable = typeof _ch === "string" && _ch.length > 0 && !/[\x00-\x1f\x7f]/.test(_ch) && !key?.ctrl && !key?.meta;
3639
+ if (printable && !typedDuringTurn) {
3640
+ typedDuringTurn = true;
3641
+ if (barPinned()) paintBar();
3642
+ }
3624
3643
  rl.line = "";
3625
3644
  rl.cursor = 0;
3626
3645
  rl.historyIndex = -1;
@@ -5446,7 +5465,9 @@ async function changeProviderModel(ps, rows, p) {
5446
5465
  // will refuse, and then the id is typed, which always works.
5447
5466
  const probe = await withPendingStatus(`asking ${p.host} what it has`, async () => {
5448
5467
  try {
5449
- const res = await proxyFetch(ps.modelsUrlFor(p.url), { signal: AbortSignal.timeout(10_000) });
5468
+ // Keyless, as above — but in the list's own dialect (the Claude API's headers, #261).
5469
+ const lr = ps.modelListRequest(p.url);
5470
+ const res = await proxyFetch(lr.url, { headers: lr.headers, signal: AbortSignal.timeout(10_000) });
5450
5471
  if (!res.ok) return { status: res.status };
5451
5472
  return { body: await res.json().catch(() => null) };
5452
5473
  } catch (err) {
@@ -5668,7 +5689,21 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
5668
5689
  console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
5669
5690
  return;
5670
5691
  }
5671
- } else {
5692
+ } else if (dialect === "openai") {
5693
+ // THE HOSTED PROVIDERS BY NAME (#261): Claude, OpenAI, Kimi — then any other URL. One picker in
5694
+ // the one add flow, so every role that adds a provider offers the same list (the embedder
5695
+ // without Claude, which serves no embeddings).
5696
+ const presets = ps.providerPresets(role);
5697
+ const rowsShown = [...presets.map((p) => `${p.name}${dim(` · ${p.url}`)}`), "another URL…"];
5698
+ console.log(dim(` Which provider? (${PICK_HINT})`));
5699
+ const pi = await pickFromList(rowsShown, 0);
5700
+ if (pi < 0) {
5701
+ console.log(dim(" (nothing saved)\n"));
5702
+ return;
5703
+ }
5704
+ if (pi < presets.length) target = as.serverTarget("openai", presets[pi].url);
5705
+ }
5706
+ if (!target && !presetUrl) {
5672
5707
  const dflt = as.defaultServerUrl(role, dialect, settings, existing);
5673
5708
  console.log(dim(dialect === "ollama"
5674
5709
  ? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
@@ -5697,6 +5732,16 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
5697
5732
  return;
5698
5733
  }
5699
5734
  const host = target.host;
5735
+ const refusal = as.roleRefusal(role, target);
5736
+ if (refusal.length) {
5737
+ for (const l of refusal) console.log(GUTTER + yellow(l));
5738
+ console.log("");
5739
+ return;
5740
+ }
5741
+ // Claude as the CODER: prompt caching needs Anthropic's SDK (#262) — offer it once, here.
5742
+ if (target.claude && role === "coding") {
5743
+ await md.ensureClaudeSdk({ log: (l) => console.log(GUTTER + dim(l.trim())), confirm: (q) => askYesNo(` ${q}`, true) });
5744
+ }
5700
5745
 
5701
5746
  // --- 2. the server's key --------------------------------------------------------------
5702
5747
  // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
@@ -6675,7 +6720,7 @@ async function chooseAgentModel() {
6675
6720
  // terminal that ignored the command. The host is named in the label because on a slow
6676
6721
  // one that IS the useful information while you wait.
6677
6722
  const r = await withPendingStatus(`asking ${hostOf(url)} for its models`, () =>
6678
- proxyFetch(url, { headers: am.agentListHeaders(listKey), signal: AbortSignal.timeout(10000) })
6723
+ proxyFetch(url, { headers: am.agentListHeaders(listKey, url), signal: AbortSignal.timeout(10000) })
6679
6724
  );
6680
6725
  if (!r.ok) failure = `HTTP ${r.status}`;
6681
6726
  if (!r.ok && needsKey && (r.status === 401 || r.status === 403)) keyProblem = true;
@@ -6982,7 +7027,7 @@ async function runS3Command(line) {
6982
7027
  };
6983
7028
 
6984
7029
  console.log("");
6985
- console.log(GUTTER + dim("RAG S3 — where the knowledge index is stored"));
7030
+ console.log(GUTTER + dim("S3 — your own bucket: the RAG knowledge index, and the files gu makes (PDFs, downloads)"));
6986
7031
  for (const row of s3.s3SummaryRows(current)) console.log(GUTTER + dim(` ${row}`));
6987
7032
  console.log("");
6988
7033
 
@@ -7083,7 +7128,7 @@ async function runS3Command(line) {
7083
7128
  console.log(GUTTER + accent("✓ saved.") + dim(" now checking it actually works…\n"));
7084
7129
  // Tested with what was just SAVED (no body) rather than what was typed: that proves the
7085
7130
  // stored config works, which is the thing every later turn will use.
7086
- await runTest({});
7131
+ return await runTest({});
7087
7132
  }
7088
7133
 
7089
7134
  /**
@@ -8077,6 +8122,16 @@ async function probeWithRecovery() {
8077
8122
  // gu-cli daemon claims + executes it — its terminal shows the full
8078
8123
  // [gu-agent] logs. We poll the job here and stream the persisted chunks live.
8079
8124
  let following = false;
8125
+ // SOMETHING WAS TYPED DURING THIS TURN (task #258). Input mid-turn is dropped on purpose (#92) — and
8126
+ // it used to be dropped SILENTLY: the row stayed blank and Enter did nothing, so a whole question
8127
+ // typed while the previous one was still queued simply vanished. Now the input row says so the
8128
+ // moment a key lands, and keeps saying it until the turn ends. Nothing is queued or sent.
8129
+ let typedDuringTurn = false;
8130
+ const TYPED_DURING_TURN_NOTICE = "(a turn is running — typing is not sent; type it again when it finishes) ";
8131
+ const TYPED_DURING_TURN_SHORT = "(turn running — not sent) ";
8132
+ /** The notice that fits: the input row must never wrap because of its label (#109/#132). */
8133
+ const typedNotice = (width) =>
8134
+ visibleLen(TYPED_DURING_TURN_NOTICE) + 12 <= width ? TYPED_DURING_TURN_NOTICE : TYPED_DURING_TURN_SHORT;
8080
8135
  // Is the turn we are following one this terminal STARTED, or another terminal's that we merely
8081
8136
  // attached to (task #211)? `attached` is local to runAgentTurn, and onInterrupt — which lives in
8082
8137
  // main() — had no way to ask. So Ctrl+C while watching somebody else's agent took the "cancel my
@@ -8160,6 +8215,9 @@ const RUNTOOL_PREFIX = "__RUNTOOL__::";
8160
8215
  const MAXSTEP_PREFIX = "__MAXSTEP__::";
8161
8216
  const TIMEOUT_PREFIX = "__TIMEOUT__::";
8162
8217
  const COMPACT_PREFIX = "__COMPACT__::";
8218
+ // Private mode, a file to hand over, and no S3 of the user's own (task #252): set it up now, or
8219
+ // save the file on this computer. Allow = S3 is now set up AND passed its test.
8220
+ const S3SETUP_PREFIX = "__S3SETUP__::";
8163
8221
  async function approvalPrompt(command) {
8164
8222
  // Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
8165
8223
  // redrawing it (the tick early-returns while a picker is active) — otherwise the block
@@ -8179,6 +8237,31 @@ async function approvalPrompt(command) {
8179
8237
  );
8180
8238
  return { allow: idx === 0, dontAsk: false };
8181
8239
  }
8240
+ if (command.startsWith(S3SETUP_PREFIX)) {
8241
+ const label = command.slice(S3SETUP_PREFIX.length);
8242
+ if (!process.stdin.isTTY) return { allow: false, dontAsk: false };
8243
+ process.stdout.write("\n" + yellow("📦 " + label) + dim(" (no answer in 2 min = save on this computer)") + "\n");
8244
+ const idx = await pickFromList(
8245
+ ["Set up S3 now — my own bucket", "No — save it on this computer"],
8246
+ 0,
8247
+ { timeoutMs: 120_000, timeoutIndex: 1 }
8248
+ );
8249
+ if (idx !== 0) return { allow: false, dontAsk: false };
8250
+ // /s3 asks ordinary questions. During a turn `following` swallows every key and drives the
8251
+ // live status block, so the turn is paused on screen for the length of the setup, then resumed.
8252
+ const wasFollowing = following;
8253
+ following = false;
8254
+ let ok = false;
8255
+ try {
8256
+ ok = (await runS3Command("/s3")) === true;
8257
+ } catch (e) {
8258
+ if (!wasPromptCancelled(e)) throw e;
8259
+ console.log(dim(" (cancelled — the file is saved on this computer)\n"));
8260
+ } finally {
8261
+ following = wasFollowing;
8262
+ }
8263
+ return { allow: ok, dontAsk: false };
8264
+ }
8182
8265
  // Time-budget prompt (task #81): the agent hit its wall-clock budget. Ask whether to
8183
8266
  // keep waiting; auto-answer No after 60s of no input so the run can't hang forever.
8184
8267
  if (command.startsWith(TIMEOUT_PREFIX)) {
@@ -8986,6 +9069,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8986
9069
  setPendingStatus(attached ? "attaching — reading its progress" : "queued — waiting for the worker");
8987
9070
  following = true;
8988
9071
  followingAttached = attached;
9072
+ typedDuringTurn = false;
8989
9073
  followAborted = false;
8990
9074
  currentJobId = jobId;
8991
9075
  jobIdForApprovalRace = jobId; // so an approval picker can watch THIS job for early end (#110)
@@ -10143,6 +10227,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
10143
10227
  if (process.stdin.isTTY) process.stdin.removeListener("data", onMouseData);
10144
10228
  following = false;
10145
10229
  followingAttached = false;
10230
+ typedDuringTurn = false;
10146
10231
  currentJobId = null;
10147
10232
  // Drop anything typed (but not submitted) during the turn — echo was muted, so it's
10148
10233
  // invisible; without this it would surface on the next prompt.
package/model-doctor.mjs CHANGED
@@ -1197,6 +1197,31 @@ export async function hasLocalOllama({ timeoutMs = 1200 } = {}) {
1197
1197
  * imports no mlx/torch/transformers and reaches the model over HTTP, exactly as it already
1198
1198
  * reaches a cloud coder.
1199
1199
  */
1200
+ /**
1201
+ * Anthropic's Python SDK — optional, and only for the Claude API (task #262). With it the coding
1202
+ * model uses the native Messages API with prompt caching; without it, the OpenAI-compatible
1203
+ * endpoint, which caches nothing, so every agent step pays full price for a prompt that is ~96%
1204
+ * repetition. Not part of ensureAgentDeps: making it required would re-prompt every install, and
1205
+ * most never use Claude. → "present" | "installed" | "skipped" | "failed".
1206
+ */
1207
+ export async function ensureClaudeSdk({ log, confirm }) {
1208
+ if (await pyHasModule("anthropic")) return "present";
1209
+ const yes = await confirm(
1210
+ "Install Anthropic's Python SDK? It turns on prompt caching for Claude — without it every step pays full price."
1211
+ );
1212
+ if (!yes) {
1213
+ log(" skipped — Claude still works, without caching. Later: pip install anthropic");
1214
+ return "skipped";
1215
+ }
1216
+ try {
1217
+ await pipInstall(["anthropic"], log);
1218
+ } catch (e) {
1219
+ log(` couldn't install it (${e?.message ?? e}) — Claude still works, without caching.`);
1220
+ return "failed";
1221
+ }
1222
+ return (await pyHasModule("anthropic")) ? "installed" : "failed";
1223
+ }
1224
+
1200
1225
  export async function ensureAgentDeps({ log, confirm, modelHost = canHostMlxModels() }) {
1201
1226
  const out = {};
1202
1227
  log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gu-cli",
3
- "version": "1.0.731",
3
+ "version": "1.0.732",
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",