@tiens.nguyen/gu-cli 1.0.730 → 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;
@@ -0,0 +1,219 @@
1
+ /**
2
+ * `gu full-log on|off|status [account]` and `/full-log` in the terminal (task #250) — ONE code path.
3
+ *
4
+ * An administrator turns "Enable Full Log" (#249) on or off for an account from a terminal. The
5
+ * command asks for an ADMIN TOKEN first — minted on the web admin page (Admin → Full Log → Create
6
+ * CLI token), 15 minutes, one use — and sends it to this machine's own API, which (hosted) decides
7
+ * or (Client Expert) forwards to the hosted API. The ordinary gu CLI token cannot do this: every
8
+ * account has one.
9
+ *
10
+ * THE TOKEN IS A SECRET HERE TOO: typed masked, never echoed, never printed, never logged, never
11
+ * saved. It lives in one local variable for one request.
12
+ *
13
+ * `status` for THIS machine's account needs no token (reading your own setting is harmless, and
14
+ * the worker reads it every 5 minutes anyway). Another account's status needs one.
15
+ */
16
+ import { mkdir, writeFile } from "node:fs/promises";
17
+ import { homedir } from "node:os";
18
+ import { dirname, join } from "node:path";
19
+
20
+ export const USAGE = "gu full-log on|off|status [email or account id]";
21
+ /** The daemon's minute tick sees this and re-reads the policy at once (see gu-cli.mjs startLogPolicy). */
22
+ export const REFRESH_FLAG = join(homedir(), ".gonext", "log-policy.refresh");
23
+ export const TOKEN_PROMPT = "Admin token (Admin → Full Log → Create CLI token), hidden:";
24
+
25
+ /** → { action: "on"|"off"|"status", account? } | { error } */
26
+ export function parseFullLogArgs(words) {
27
+ const w = (words ?? []).map((s) => String(s).trim()).filter(Boolean);
28
+ const action = (w[0] ?? "status").toLowerCase();
29
+ if (!["on", "off", "status"].includes(action)) return { error: `unknown "${w[0]}" — ${USAGE}` };
30
+ if (w.length > 2) return { error: `too many words — ${USAGE}` };
31
+ return w[1] ? { action, account: w[1] } : { action };
32
+ }
33
+
34
+ const onOff = (b) => (b ? "ON" : "OFF");
35
+
36
+ /** Tell a running worker on this machine to re-read its policy now, instead of within 5 minutes. */
37
+ export async function requestLocalRefresh(file = REFRESH_FLAG) {
38
+ try {
39
+ await mkdir(dirname(file), { recursive: true });
40
+ await writeFile(file, new Date().toISOString() + "\n", "utf8");
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async function readJson(res) {
48
+ return (await res.json().catch(() => null)) ?? {};
49
+ }
50
+
51
+ /**
52
+ * Run it. `askSecret(label)` → the typed token ("" = cancelled). `print(line)` writes one line.
53
+ * → an exit code: 0 done, 1 refused / failed, 2 usage.
54
+ */
55
+ export async function runFullLog({
56
+ action,
57
+ account,
58
+ apiBase,
59
+ workerKey,
60
+ askSecret,
61
+ print = (l) => console.log(l),
62
+ fetchImpl = fetch,
63
+ requestRefresh = requestLocalRefresh,
64
+ }) {
65
+ const base = String(apiBase ?? "").trim().replace(/\/+$/, "");
66
+ const key = String(workerKey ?? "").trim();
67
+ if (!base || !key) {
68
+ print("This computer is not signed in to gu yet — run `gu login` first.");
69
+ return 1;
70
+ }
71
+ const who = account ? account : "this computer's account";
72
+
73
+ // Your own status: no token.
74
+ if (action === "status" && !account) {
75
+ let res;
76
+ try {
77
+ res = await fetchImpl(`${base}/api/worker/log-policy`, {
78
+ headers: { "X-Worker-Key": key },
79
+ signal: AbortSignal.timeout(10_000),
80
+ });
81
+ } catch {
82
+ print(`Could not reach ${base} — try again in a moment.`);
83
+ return 1;
84
+ }
85
+ const body = await readJson(res);
86
+ if (res.status === 404) {
87
+ print("Full Log is not available on this gu service yet — this computer keeps full logs until it is.");
88
+ return 0;
89
+ }
90
+ if (!res.ok || typeof body.fullLog !== "boolean") {
91
+ print(`Could not read the log setting: ${body.error ?? `HTTP ${res.status}`}.`);
92
+ return 1;
93
+ }
94
+ print(
95
+ body.fullLog
96
+ ? "Full Log: ON for this computer's account — its logs include what was asked and answered."
97
+ : "Full Log: OFF for this computer's account — its logs carry no conversation content (the default)."
98
+ );
99
+ print(`To change it (administrators): ${USAGE.replace("on|off|status", "on|off")}`);
100
+ return 0;
101
+ }
102
+
103
+ let token = "";
104
+ try {
105
+ token = String((await askSecret(TOKEN_PROMPT)) ?? "").trim();
106
+ } catch {
107
+ token = "";
108
+ }
109
+ if (!token) {
110
+ print("No token entered — nothing changed.");
111
+ return 1;
112
+ }
113
+
114
+ const body = action === "status" ? { account } : { account, fullLog: action === "on" };
115
+ let res;
116
+ try {
117
+ res = await fetchImpl(`${base}/api/worker/log-policy/admin`, {
118
+ method: "POST",
119
+ headers: { "X-Worker-Key": key, Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
120
+ body: JSON.stringify(body),
121
+ signal: AbortSignal.timeout(20_000),
122
+ });
123
+ } catch {
124
+ print(`Could not reach ${base} — nothing changed.`);
125
+ return 1;
126
+ } finally {
127
+ token = ""; // one request, then gone
128
+ }
129
+ const a = await readJson(res);
130
+ if (!res.ok) {
131
+ if (res.status === 404 && typeof a.error !== "string") {
132
+ print("This computer's gu service does not support `gu full-log` yet — it needs an update. Nothing changed.");
133
+ } else {
134
+ const why = a.error ?? `HTTP ${res.status}`;
135
+ const tail = action === "status" || /nothing (was )?changed/i.test(why) ? "" : " Nothing changed.";
136
+ print(`${res.status >= 500 ? "Failed" : "Refused"}: ${why}${tail}`);
137
+ }
138
+ return 1;
139
+ }
140
+ const name = a.email ? `${a.email} (${a.userId})` : a.self ? `this computer's account (${a.userId})` : String(a.userId ?? who);
141
+ if (action === "status") {
142
+ print(`Full Log: ${onOff(a.fullLog)} for ${name}.`);
143
+ if (a.lastChange) {
144
+ const lc = a.lastChange;
145
+ print(`Last changed ${String(lc.at).replace("T", " ").slice(0, 16)} UTC by ${lc.by}${lc.via === "cli" ? " from the CLI" : ""}.`);
146
+ }
147
+ return 0;
148
+ }
149
+ print(
150
+ a.changed
151
+ ? `Full Log ${onOff(a.fullLog)} for ${name} — it was ${onOff(a.previous)}.`
152
+ : `Full Log was already ${onOff(a.fullLog)} for ${name} — nothing changed (recorded).`
153
+ );
154
+ if (a.self) {
155
+ await requestRefresh();
156
+ print("This computer picks it up within a minute; the account's other computers within 5 minutes.");
157
+ } else {
158
+ print("That account's computers pick it up within 5 minutes.");
159
+ }
160
+ return 0;
161
+ }
162
+
163
+ /**
164
+ * A masked line from a plain terminal (no REPL) — `gu full-log` in a shell. A bullet per
165
+ * character, nothing of the value on screen; Ctrl+C / Ctrl+D cancel (→ ""). Piped stdin (a
166
+ * script: `printf '%s\n' "$TOKEN" | gu full-log on`) reads the first line, echoing nothing.
167
+ */
168
+ export function readHiddenLine(label, { input = process.stdin, output = process.stderr } = {}) {
169
+ return new Promise((resolve) => {
170
+ output.write(label + " ");
171
+ if (!input.isTTY) {
172
+ let buf = "";
173
+ const onData = (d) => {
174
+ buf += d.toString("utf8");
175
+ const nl = buf.indexOf("\n");
176
+ if (nl >= 0) finish(buf.slice(0, nl));
177
+ };
178
+ const onEnd = () => finish(buf);
179
+ const finish = (v) => {
180
+ input.off("data", onData);
181
+ input.off("end", onEnd);
182
+ input.pause();
183
+ output.write("\n");
184
+ resolve(v.replace(/\r$/, "").trim());
185
+ };
186
+ input.on("data", onData);
187
+ input.on("end", onEnd);
188
+ input.resume();
189
+ return;
190
+ }
191
+ let value = "";
192
+ input.setRawMode(true);
193
+ input.resume();
194
+ const done = (v) => {
195
+ input.off("data", onData);
196
+ input.setRawMode(false);
197
+ input.pause();
198
+ output.write("\n");
199
+ resolve(v);
200
+ };
201
+ const onData = (d) => {
202
+ for (const ch of d.toString("utf8")) {
203
+ if (ch === "\r" || ch === "\n") return done(value.trim());
204
+ if (ch === "\u0003" || ch === "\u0004") return done("");
205
+ if (ch === "\u007f" || ch === "\b") {
206
+ if (value) {
207
+ value = value.slice(0, -1);
208
+ output.write("\b \b");
209
+ }
210
+ continue;
211
+ }
212
+ if (ch < " ") continue; // other control bytes: ignored, never stored
213
+ value += ch;
214
+ output.write("•");
215
+ }
216
+ };
217
+ input.on("data", onData);
218
+ });
219
+ }
@@ -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-cli.mjs CHANGED
@@ -15,7 +15,7 @@ import { requireSupportedNode } from "./node-version.mjs";
15
15
  // The brand accent, shared with the REPL — see accent.mjs.
16
16
  import { accent } from "./accent.mjs";
17
17
  requireSupportedNode();
18
- import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
18
+ import { mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
19
19
  import { execFile as execFileCallback, spawn } from "node:child_process";
20
20
  import { createHash } from "node:crypto";
21
21
  import { createTaskPool, createFolderQueue } from "./job-pools.mjs";
@@ -76,6 +76,9 @@ gu-cli
76
76
  Usage:
77
77
  gu-cli
78
78
  gu-cli login [--api-base <url>] # browser sign-in → writes ~/.gonext/worker.env
79
+ gu-cli full-log on|off|status [account] # administrators: Enable Full Log for an account (asks for
80
+ # an admin token from the web admin page first); status
81
+ # alone shows this computer's account, no token needed
79
82
  gu-cli token [<token>] # show this computer's gu token, or save one pasted from
80
83
  # the Account page (Client Expert machines)
81
84
  gu-cli set <workerKey> [--api-base <url>]
@@ -220,6 +223,24 @@ if (args[0] === "token") {
220
223
  process.exit(0);
221
224
  }
222
225
 
226
+ if (args[0] === "full-log") {
227
+ // #250. An administrator switches Enable Full Log (#249) from a terminal — after typing an admin
228
+ // token minted on the web admin page. Same code as /full-log in the terminal UI.
229
+ const fl = await import("./full-log-command.mjs");
230
+ const parsed = fl.parseFullLogArgs(args.slice(1));
231
+ if (parsed.error) {
232
+ console.error(parsed.error);
233
+ process.exit(2);
234
+ }
235
+ const code = await fl.runFullLog({
236
+ ...parsed,
237
+ apiBase: process.env.GONEXT_API_BASE,
238
+ workerKey: process.env.GONEXT_WORKER_KEY,
239
+ askSecret: (label) => fl.readHiddenLine(label),
240
+ });
241
+ process.exit(code);
242
+ }
243
+
223
244
  if (args[0] === "login") {
224
245
  // Task #127, Phase 1: browser device-pairing → writes worker.env. `--api-base` overrides
225
246
  // which API to pair against (defaults to the production API).
@@ -5067,10 +5088,13 @@ async function startLogPolicy() {
5067
5088
  };
5068
5089
  await housekeep();
5069
5090
  let ticks = 0;
5091
+ const { REFRESH_FLAG } = await import("./full-log-command.mjs");
5070
5092
  setInterval(() => {
5071
5093
  ticks++;
5072
5094
  void (async () => {
5073
- if (ticks % Math.round(lp.REFRESH_MS / 60_000) === 0) await logPolicy.refresh();
5095
+ // `gu full-log` just changed this machine's account (#250): re-read now, not in 5 minutes.
5096
+ const asked = await unlink(REFRESH_FLAG).then(() => true, () => false);
5097
+ if (asked || ticks % Math.round(lp.REFRESH_MS / 60_000) === 0) await logPolicy.refresh();
5074
5098
  await housekeep();
5075
5099
  })().catch(() => {});
5076
5100
  }, 60_000).unref();
package/gu-repl.mjs CHANGED
@@ -532,7 +532,7 @@ const argv = process.argv.slice(2);
532
532
  // process.argv and exits inside its own subcommand branch, before it ever starts polling.
533
533
  const CLI_SUBCOMMANDS = [
534
534
  "status", "start", "stop", "restart", "logs", "doctor", "login", "set", "rag",
535
- "clear", "reset", "api", "token-usage", "tokens", "token",
535
+ "clear", "reset", "api", "token-usage", "tokens", "token", "full-log",
536
536
  ];
537
537
  // `gu logz` USED TO START THE REPL (task #181). Silently: an unrecognised word was not in
538
538
  // the list, so it fell through to "no subcommand given" and a full agent session opened in that
@@ -800,12 +800,13 @@ 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" },
807
807
  { name: "/doctor", desc: "check every configured model ANSWERS — especially the embedder, which a turn only reaches after several steps" },
808
808
  { name: "/tokens", desc: "break down the last prompt sent to the coding model — how much of it was RAG" },
809
+ { name: "/full-log", usage: "/full-log [on|off|status] [account]", desc: "administrators: Enable Full Log for an account — asks for an admin token from the web admin page first" },
809
810
  { name: "/help", desc: "list these commands" },
810
811
  { name: "/exit", aliases: ["/quit"], desc: "quit" },
811
812
  ];
@@ -1514,6 +1515,11 @@ rl.on("line", (l) => {
1514
1515
  if (following) {
1515
1516
  rl.line = "";
1516
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
+ }
1517
1523
  return;
1518
1524
  }
1519
1525
  // Enter left the cursor on the row where the live slash-hint started — erase it so the
@@ -2262,7 +2268,12 @@ function frozenZone() {
2262
2268
  const zone = inputZoneRows({
2263
2269
  headControl,
2264
2270
  width,
2265
- 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,
2266
2277
  // WHILE A SECRET IS OPEN, THIS ROW IS THE PROMPT — "kimi-k3 API key: ••••" rather than
2267
2278
  // ">> ••••". Same row, same masking; what changes is that it now says what it wants. The
2268
2279
  // width is measured, not assumed: the label is caller-supplied and every offset in here
@@ -2270,9 +2281,11 @@ function frozenZone() {
2270
2281
  promptCols:
2271
2282
  secretInput && secretPrompt
2272
2283
  ? visibleLen(secretPrompt)
2273
- : inlinePrompt
2274
- ? visibleLen(inlinePrompt)
2275
- : PROMPT_COLS,
2284
+ : following && typedDuringTurn && !inlinePrompt
2285
+ ? visibleLen(typedNotice(width))
2286
+ : inlinePrompt
2287
+ ? visibleLen(inlinePrompt)
2288
+ : PROMPT_COLS,
2276
2289
  // A SECRET IS MASKED HERE, NOT HIDDEN (task #191, and a #127 hole the frozen zone opened).
2277
2290
  // askSecret mutes readline's echo, but this zone does not go through readline — gu
2278
2291
  // renders it from rl.line — so while the key prompt was up, the characters being typed
@@ -3620,6 +3633,13 @@ if (process.stdin.isTTY) {
3620
3633
  // so the switch did nothing while the agent was thinking — which is exactly when a user
3621
3634
  // watching it reach for commands wants to change it.
3622
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
+ }
3623
3643
  rl.line = "";
3624
3644
  rl.cursor = 0;
3625
3645
  rl.historyIndex = -1;
@@ -5445,7 +5465,9 @@ async function changeProviderModel(ps, rows, p) {
5445
5465
  // will refuse, and then the id is typed, which always works.
5446
5466
  const probe = await withPendingStatus(`asking ${p.host} what it has`, async () => {
5447
5467
  try {
5448
- 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) });
5449
5471
  if (!res.ok) return { status: res.status };
5450
5472
  return { body: await res.json().catch(() => null) };
5451
5473
  } catch (err) {
@@ -5667,7 +5689,21 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
5667
5689
  console.log(red(` "${presetUrl}" is ${target.why} — nothing changed.\n`));
5668
5690
  return;
5669
5691
  }
5670
- } 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) {
5671
5707
  const dflt = as.defaultServerUrl(role, dialect, settings, existing);
5672
5708
  console.log(dim(dialect === "ollama"
5673
5709
  ? " An Ollama server: this machine, a box on your network, or https://ollama.com (needs a key)."
@@ -5696,6 +5732,16 @@ async function addServer({ role = "coding", dialect = "openai", url: presetUrl =
5696
5732
  return;
5697
5733
  }
5698
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
+ }
5699
5745
 
5700
5746
  // --- 2. the server's key --------------------------------------------------------------
5701
5747
  // AN EXISTING SERVER IS REUSED, never stored twice (two rows for one URL would be two picker
@@ -6674,7 +6720,7 @@ async function chooseAgentModel() {
6674
6720
  // terminal that ignored the command. The host is named in the label because on a slow
6675
6721
  // one that IS the useful information while you wait.
6676
6722
  const r = await withPendingStatus(`asking ${hostOf(url)} for its models`, () =>
6677
- proxyFetch(url, { headers: am.agentListHeaders(listKey), signal: AbortSignal.timeout(10000) })
6723
+ proxyFetch(url, { headers: am.agentListHeaders(listKey, url), signal: AbortSignal.timeout(10000) })
6678
6724
  );
6679
6725
  if (!r.ok) failure = `HTTP ${r.status}`;
6680
6726
  if (!r.ok && needsKey && (r.status === 401 || r.status === 403)) keyProblem = true;
@@ -6981,7 +7027,7 @@ async function runS3Command(line) {
6981
7027
  };
6982
7028
 
6983
7029
  console.log("");
6984
- 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)"));
6985
7031
  for (const row of s3.s3SummaryRows(current)) console.log(GUTTER + dim(` ${row}`));
6986
7032
  console.log("");
6987
7033
 
@@ -7082,7 +7128,29 @@ async function runS3Command(line) {
7082
7128
  console.log(GUTTER + accent("✓ saved.") + dim(" now checking it actually works…\n"));
7083
7129
  // Tested with what was just SAVED (no body) rather than what was typed: that proves the
7084
7130
  // stored config works, which is the thing every later turn will use.
7085
- await runTest({});
7131
+ return await runTest({});
7132
+ }
7133
+
7134
+ /**
7135
+ * /full-log (#250) — the same code as `gu full-log`. The admin token goes through askSecret: masked
7136
+ * in the frozen row, dropped from readline history, never echoed (#191).
7137
+ */
7138
+ async function runFullLogCommand(line) {
7139
+ const fl = await import("./full-log-command.mjs");
7140
+ const parsed = fl.parseFullLogArgs(line.split(/\s+/).slice(1));
7141
+ if (parsed.error) {
7142
+ console.log(red(parsed.error) + "\n");
7143
+ return;
7144
+ }
7145
+ // A cancelled token prompt (Esc / Ctrl+C) reads as "no token entered" inside runFullLog.
7146
+ await fl.runFullLog({
7147
+ ...parsed,
7148
+ apiBase,
7149
+ workerKey,
7150
+ askSecret: (label) => askSecret(` ${label}`),
7151
+ print: (l) => console.log(gutterLines(l)),
7152
+ });
7153
+ console.log("");
7086
7154
  }
7087
7155
 
7088
7156
  async function runProxyCommand(line) {
@@ -8054,6 +8122,16 @@ async function probeWithRecovery() {
8054
8122
  // gu-cli daemon claims + executes it — its terminal shows the full
8055
8123
  // [gu-agent] logs. We poll the job here and stream the persisted chunks live.
8056
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;
8057
8135
  // Is the turn we are following one this terminal STARTED, or another terminal's that we merely
8058
8136
  // attached to (task #211)? `attached` is local to runAgentTurn, and onInterrupt — which lives in
8059
8137
  // main() — had no way to ask. So Ctrl+C while watching somebody else's agent took the "cancel my
@@ -8137,6 +8215,9 @@ const RUNTOOL_PREFIX = "__RUNTOOL__::";
8137
8215
  const MAXSTEP_PREFIX = "__MAXSTEP__::";
8138
8216
  const TIMEOUT_PREFIX = "__TIMEOUT__::";
8139
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__::";
8140
8221
  async function approvalPrompt(command) {
8141
8222
  // Wipe the live "thinking" status block BEFORE any picker draws, and stop the ticker from
8142
8223
  // redrawing it (the tick early-returns while a picker is active) — otherwise the block
@@ -8156,6 +8237,31 @@ async function approvalPrompt(command) {
8156
8237
  );
8157
8238
  return { allow: idx === 0, dontAsk: false };
8158
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
+ }
8159
8265
  // Time-budget prompt (task #81): the agent hit its wall-clock budget. Ask whether to
8160
8266
  // keep waiting; auto-answer No after 60s of no input so the run can't hang forever.
8161
8267
  if (command.startsWith(TIMEOUT_PREFIX)) {
@@ -8963,6 +9069,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
8963
9069
  setPendingStatus(attached ? "attaching — reading its progress" : "queued — waiting for the worker");
8964
9070
  following = true;
8965
9071
  followingAttached = attached;
9072
+ typedDuringTurn = false;
8966
9073
  followAborted = false;
8967
9074
  currentJobId = jobId;
8968
9075
  jobIdForApprovalRace = jobId; // so an approval picker can watch THIS job for early end (#110)
@@ -10120,6 +10227,7 @@ async function runAgentTurn(history, { attachJobId = "" } = {}) {
10120
10227
  if (process.stdin.isTTY) process.stdin.removeListener("data", onMouseData);
10121
10228
  following = false;
10122
10229
  followingAttached = false;
10230
+ typedDuringTurn = false;
10123
10231
  currentJobId = null;
10124
10232
  // Drop anything typed (but not submitted) during the turn — echo was muted, so it's
10125
10233
  // invisible; without this it would surface on the next prompt.
@@ -11065,6 +11173,10 @@ async function main() {
11065
11173
  await saveSession(cwd, history); // remember the pick for this folder
11066
11174
  continue;
11067
11175
  }
11176
+ if (line === "/full-log" || line.startsWith("/full-log ")) {
11177
+ await runFullLogCommand(line);
11178
+ continue;
11179
+ }
11068
11180
  if (line === "/proxy" || line.startsWith("/proxy ")) {
11069
11181
  await runProxyCommand(line);
11070
11182
  continue;
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.730",
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",
@@ -57,6 +57,7 @@
57
57
  "device-login.mjs",
58
58
  "daemon-control.mjs",
59
59
  "log-policy.mjs",
60
+ "full-log-command.mjs",
60
61
  "poll-errors.mjs",
61
62
  "node-version.mjs",
62
63
  "clear-state.mjs",